Compare commits

..

236 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
620 changed files with 33714 additions and 64174 deletions
+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
+2 -7
View File
@@ -6,7 +6,6 @@ on:
branches:
- ci
- dev
- v2
- beta
- fix/npm-native-binary-install
- snapshot-*
@@ -32,9 +31,6 @@ permissions:
contents: write
packages: write
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
jobs:
version:
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -126,7 +122,7 @@ jobs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
if: github.repository == 'anomalyco/opencode'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -225,7 +221,7 @@ jobs:
needs:
- build-cli
- version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
if: github.repository == 'anomalyco/opencode'
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -451,7 +447,6 @@ jobs:
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
+5 -2
View File
@@ -4,7 +4,6 @@ on:
push:
branches:
- dev
- v2
pull_request:
workflow_dispatch:
@@ -75,9 +74,13 @@ jobs:
working-directory: packages/client
run: bun run check:generated
- name: Run HttpApi exerciser gates
if: runner.os == 'Linux'
working-directory: packages/opencode
run: bun run test:httpapi
e2e:
name: e2e (${{ matrix.settings.name }})
if: github.ref_name != 'v2' && github.head_ref != 'v2'
strategy:
fail-fast: false
matrix:
-16
View File
@@ -1,16 +0,0 @@
export default {
id: "sample-agent-plugin",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("sample-plugin-agent", (agent) => {
agent.description = "Example primary agent registered by .opencode/plugins/sample-agent.ts"
agent.mode = "primary"
agent.system = [
"You are the sample plugin agent for this repository.",
"Use this agent to verify that local plugin auto-discovery can add agents.",
"Keep responses concise and explain which plugin registered you when asked.",
].join("\n")
})
})
},
}
-144
View File
@@ -1,144 +0,0 @@
---
name: debug-opencode
description: Use when interactively running, debugging, or verifying opencode's own V2 CLI/TUI or server during development in this repo — starting the dev TUI, driving it with termctrl, comparing V2 against the legacy TUI, hitting the V2 server/API directly, reading log files, or attaching Bun's inspector.
---
# Debugging opencode itself
Workflow for interactively exercising the V2 CLI/TUI and server while developing in this repo. All commands below run from `packages/cli` unless noted otherwise.
## Migration context
- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI (see "Comparing V2 against the legacy TUI" below) rather than guessing.
## Server/client model
opencode V2 is a client/server system, not a single monolithic process:
- **Server process** runs the Effect HTTP API (`packages/server`) and owns all domain state: sessions, database, plugins, permissions, Location services. It's started by the `serve` command (`packages/cli/src/commands/handlers/serve.ts`).
- **TUI process** is a separate process that runs no application logic itself — it's an HTTP/SSE client of the server via the generated SDK (`createOpencodeClient` / `sdk.client.v2`).
- **Discovery**: CLI processes find the shared server through a JSON registration file at `~/.local/state/opencode/service.json` (or `service-local.json` for the local/dev channel) containing `{id, version, url, pid}`. A separate password file under `~/.config/opencode/service.json` provides HTTP Basic auth. Before reusing a registration, the client calls `GET /health` to confirm the server is alive, authenticated, and version-compatible.
- **Sharing**: because of this registration/health-check dance, many concurrent `opencode`/TUI invocations converge on one shared background daemon rather than each spawning their own. If no compatible healthy daemon is found, a new one is spawned detached (`serve --service`) and registers itself.
- **`bun dev service start|status|stop|restart`** manages this shared background daemon's lifecycle directly — useful when you need to force a fresh server, confirm one is running, or kill a stuck one.
- **Standalone mode** (`--standalone`) opts a single invocation out of the shared daemon: it spawns a private one-off `serve --stdio --port 0` child tied to that invocation's lifetime, with its own random password. Use this to isolate a debugging session from your other running opencode sessions.
- Every log line is tagged `role=server` or `role=cli` and a per-process `run=<id>`, so you can distinguish server-side and client-side activity in one shared log file (see "Logs" below) even when both roles are interleaved from concurrent processes.
## Starting the dev TUI
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server.
## Interactive debugging with termctrl
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
- Use a dedicated session name and do not reuse or kill an unrelated session.
```bash
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
termctrl show opencode-v2-dev
```
- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`.
- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input.
```bash
termctrl send opencode-v2-dev 'text:example prompt' enter
termctrl send opencode-v2-dev ctrl-c
```
- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits.
- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed.
```bash
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png
```
- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again:
```bash
termctrl resize opencode-v2-dev --cols 100 --rows 30
termctrl show opencode-v2-dev
```
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
- Always clean up the Terminal Control session when the check is complete:
```bash
termctrl stop opencode-v2-dev
```
## Comparing V2 against the legacy TUI
Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
```bash
# From packages/cli: local V2 TUI
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
# Released legacy TUI behavior reference
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
```
- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints.
- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`.
## Server/API debugging
- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI.
- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering.
- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path:
```bash
bun dev api get /health
bun dev api get /openapi.json
bun dev api <operationId> --param key=value
```
- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`.
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
## Logs
- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine.
- Each line is structured `key=value` text: `timestamp`, `level`, `run=<id>` (per-process run ID), `message`, and a `role=cli` or `role=server` tag. Use `run=` to isolate one process's activity and `role=` to separate client-side from server-side log lines, since a shared daemon interleaves many processes' output in one file.
- Tail the live file while reproducing an issue instead of guessing from stale output:
```bash
tail -f ~/.local/share/opencode/log/opencode-local.log
```
- Filter to one run or role when the file is noisy:
```bash
grep 'run=8fc3b1d5' ~/.local/share/opencode/log/opencode-local.log
grep 'role=server' ~/.local/share/opencode/log/opencode-local.log
```
- `OPENCODE_LOG_LEVEL` controls verbosity (default `INFO`); set it before starting `bun dev` or `serve` to get `DEBUG` output for a specific repro.
- `OPENCODE_PRINT_LOGS=1` additionally tees log output to stderr of the process that emitted it, which is useful when a process fails before you'd think to check the shared log file.
- `termctrl logs <session>` surfaces stdout/stderr for a Terminal Control session specifically (e.g. inspector output or startup failures before the TUI renderer starts) — use the log file above for anything emitted by a separate server/daemon process instead.
## Debugger
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
```bash
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
bun run --inspect=ws://localhost:6499/ src/index.ts
```
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches.
- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI.
## Verification
- Run `bun typecheck` from `packages/cli` after CLI adapter changes.
- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root.
- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state.
-31
View File
@@ -1,31 +0,0 @@
---
name: sample-skill
description: Use when the user says sample skill, skill demo, or asks how an opencode SKILL.md should be structured; demonstrates a tiny project-local skill with practical assistant workflow guidance.
---
# Sample Skill
This is a minimal project-local opencode skill. It exists as a reference for how a skill is structured and as a tiny reusable workflow the assistant can load when the user asks for a skill example.
## When To Use
- Use when the user asks for a sample skill or skill template.
- Use when demonstrating the required `SKILL.md` frontmatter and body format.
- Do not use for unrelated coding tasks just because a skill exists.
## Workflow
- Confirm the specific outcome the user wants if the request is ambiguous.
- Inspect the relevant files before changing anything.
- Make the smallest correct change.
- Verify the result with a focused read, typecheck, test, or other lightweight check when available.
- Summarize the changed files and any required restart or reload step.
## Example Response Style
When this skill is relevant, keep responses direct and actionable:
```text
I created a project-local skill at .opencode/skills/sample-skill/SKILL.md.
Restart opencode for the new skill to be discovered by future sessions.
```
-1
View File
@@ -1,7 +1,6 @@
- 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.
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
+150 -100
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",
@@ -37,6 +38,7 @@
"@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:*",
@@ -94,11 +96,10 @@
"name": "@opencode-ai/cli",
"version": "1.17.13",
"bin": {
"opencode2": "./bin/opencode2.cjs",
"lildax": "./bin/lildax.cjs",
},
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
@@ -107,15 +108,12 @@
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
"jsonc-parser": "3.3.1",
"semver": "catalog:",
"solid-js": "catalog:",
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
@@ -142,6 +140,20 @@
"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.13",
@@ -304,7 +316,6 @@
"@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-bun": "0.9.4",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
@@ -558,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",
@@ -583,7 +594,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -680,9 +691,6 @@
"version": "1.17.13",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
"zod": "catalog:",
@@ -691,7 +699,6 @@
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -710,7 +717,6 @@
},
"packages/protocol": {
"name": "@opencode-ai/protocol",
"version": "1.17.11",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:",
@@ -719,12 +725,10 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/schema": {
"name": "@opencode-ai/schema",
"version": "1.17.11",
"dependencies": {
"effect": "catalog:",
},
@@ -732,7 +736,6 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/script": {
@@ -750,7 +753,6 @@
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/server": "workspace:*",
"effect": "catalog:",
},
@@ -944,7 +946,6 @@
"name": "@opencode-ai/tui",
"version": "1.17.13",
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -952,7 +953,6 @@
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
@@ -1940,6 +1940,8 @@
"@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"],
@@ -3024,9 +3026,9 @@
"abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"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=="],
@@ -3194,7 +3196,7 @@
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
"bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="],
@@ -3362,7 +3364,7 @@
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
@@ -3372,7 +3374,7 @@
"cookie-es": ["cookie-es@2.0.1", "", {}, "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
@@ -3598,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=="],
@@ -3680,7 +3682,7 @@
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"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=="],
"express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="],
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
@@ -3738,7 +3740,7 @@
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
@@ -3776,7 +3778,7 @@
"framer-motion": ["framer-motion@8.5.5", "", { "dependencies": { "@motionone/dom": "^10.15.3", "hey-listen": "^1.0.8", "tslib": "^2.4.0" }, "optionalDependencies": { "@emotion/is-prop-valid": "^0.8.2" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-5IDx5bxkjWHWUF3CVJoSyUVOtrbAxtzYBBowRE2uYI/6VYhkEBD+rbTHEGuUmbGHRj6YqqSfoG7Aa1cLyWCrBA=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
@@ -4340,11 +4342,11 @@
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
@@ -4520,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=="],
@@ -4830,7 +4832,7 @@
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
@@ -5016,7 +5018,7 @@
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
@@ -5026,7 +5028,7 @@
"seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
@@ -5342,7 +5344,7 @@
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
@@ -5370,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=="],
@@ -5572,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=="],
@@ -5620,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=="],
@@ -5708,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=="],
@@ -5872,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=="],
@@ -5882,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=="],
@@ -5918,12 +5926,18 @@
"@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=="],
"@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
"@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
@@ -5932,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=="],
@@ -5992,8 +6008,6 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode-ai/core/@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/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=="],
@@ -6060,12 +6074,8 @@
"@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@slack/bolt/express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="],
"@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"@slack/bolt/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
"@slack/oauth/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
"@slack/socket-mode/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
@@ -6132,6 +6142,10 @@
"@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.107", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8nT08pGPy25rleJNk56ep00UHK6kCtCmu+ZNqVVSSPDieADlIZqcaN1iRXAFBoCH0Fb9F6C2EjFDaySdsargfQ=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
@@ -6166,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=="],
@@ -6186,6 +6202,10 @@
"babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
@@ -6202,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=="],
@@ -6252,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=="],
@@ -6264,10 +6288,16 @@
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"express/path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
@@ -6310,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=="],
@@ -6330,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=="],
@@ -6342,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=="],
@@ -6392,6 +6424,8 @@
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
"rimraf/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=="],
@@ -6400,6 +6434,10 @@
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -6428,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=="],
@@ -6444,10 +6482,12 @@
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"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=="],
@@ -6462,6 +6502,8 @@
"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=="],
@@ -6714,6 +6756,28 @@
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"@modelcontextprotocol/sdk/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"@modelcontextprotocol/sdk/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
"@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
@@ -6836,34 +6900,6 @@
"@shikijs/stream/@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@slack/bolt/express/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"@slack/bolt/express/body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
"@slack/bolt/express/content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
"@slack/bolt/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"@slack/bolt/express/cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
"@slack/bolt/express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"@slack/bolt/express/finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
"@slack/bolt/express/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
"@slack/bolt/express/merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
"@slack/bolt/express/path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
"@slack/bolt/express/send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
"@slack/bolt/express/serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
"@slack/bolt/express/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"@slack/bolt/raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
@@ -6886,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=="],
@@ -6894,6 +6932,8 @@
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@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=="],
@@ -6912,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=="],
@@ -6958,6 +6996,8 @@
"babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
@@ -7016,8 +7056,12 @@
"esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"filelist/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
@@ -7038,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=="],
@@ -7054,6 +7110,8 @@
"rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"storybook/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -7068,6 +7126,8 @@
"tw-to-css/tailwindcss/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
@@ -7260,6 +7320,10 @@
"@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"@octokit/auth-app/@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/auth-app/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
@@ -7292,20 +7356,6 @@
"@sentry/bundler-plugin-core/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"@slack/bolt/express/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"@slack/bolt/express/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"@slack/bolt/express/body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"@slack/bolt/express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"@slack/bolt/express/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"@slack/bolt/express/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"@slack/bolt/express/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"@slack/web-api/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
@@ -7318,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=="],
@@ -7340,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=="],
@@ -7382,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=="],
@@ -7434,10 +7486,6 @@
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@slack/bolt/express/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"@slack/bolt/express/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="],
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="],
@@ -7452,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=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-zjYaBa+TKuHVBO0z0YqOOFLOxpxj2hB6Iq1o+BXBeVc=",
"aarch64-linux": "sha256-S2QDDoKcg8QCqjxCnMmLp/3xmiVHAbCJ0CuHQRjDBbE=",
"aarch64-darwin": "sha256-GtdfHbk4fhvosgo6iEjXrWSxteK+eNrT0P9swEccbew=",
"x86_64-darwin": "sha256-zmWFdPSTS/DpU40CgHA/KKiSIs0PlI092TzLv/cG0jE="
"x86_64-linux": "sha256-ZmAIcG3wPgssKejLe/EMJOjPuMHCyRiiItRvSqIjaME=",
"aarch64-linux": "sha256-lbAHoVI/CvgdrfKZ/9dKZ/O7DxJvuWEOrDgcCuFGPBk=",
"aarch64-darwin": "sha256-Nrs4ijvzQ3J8AYigJSlsr66feUvLZ5iiYCfBdaZNJEc=",
"x86_64-darwin": "sha256-ltCaJSO7kZWuMOU49269xvboNVX35OJDFj0gXCDf62Y="
}
}
+2 -2
View File
@@ -2,12 +2,11 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
@@ -100,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",
@@ -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" },
])
}
@@ -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,36 +93,53 @@ const assistantMessage = {
parts: [editPart],
}
export async function setupTimelineBenchmark(page: Page, options: { historyTurns: number; eventBatch: number }) {
export async function setupTimelineBenchmark(
page: Page,
options: {
historyTurns: number
eventBatch: number
newLayoutDesigns?: boolean
vcsDiff?: unknown[]
turnDiffs?: unknown[]
},
) {
const events: EventPayload[] = []
let eventBatch = options.eventBatch
const currentUserMessage = options.turnDiffs
? { ...userMessage, info: { ...userMessage.info, summary: { diffs: options.turnDiffs } } }
: userMessage
await mockOpenCodeServer(page, {
directory,
project: project(),
provider: provider(),
sessions: [session()],
vcsDiff: options.vcsDiff,
pageMessages: () => ({
items: [
...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(),
userMessage,
currentUserMessage,
assistantMessage,
],
}),
events: () => events.splice(0, eventBatch),
eventRetry: 16,
})
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
},
}),
)
})
await page.addInitScript(
(input) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
newLayoutDesigns: input.newLayoutDesigns,
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
},
}),
)
},
{ newLayoutDesigns: options.newLayoutDesigns ?? false },
)
await page.setViewportSize({ width: 1366, height: 768 })
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()
@@ -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)),
}
}
@@ -21,7 +21,10 @@ export async function installTimelineSettings(page: Page) {
export function mockStressTimeline(
page: Page,
input?: { onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void },
input?: {
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
vcsDiff?: unknown[]
},
) {
return mockOpenCodeServer(page, {
sessions: fixture.sessions,
@@ -30,6 +33,7 @@ export function mockStressTimeline(
project: fixture.project,
pageMessages,
onMessages: input?.onMessages,
vcsDiff: input?.vcsDiff,
})
}
@@ -78,3 +82,53 @@ export function stressDraftHref(draftID: string) {
function stressServer() {
return `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
}
export function createReviewDiffs() {
return Array.from({ length: Number(process.env.REVIEW_PANE_DIFF_COUNT ?? 72) }, (_, index) => {
const lines = index % 3 === 0 ? 300 : index % 3 === 1 ? 120 : 38
const file = `src/review/generated-${String(index).padStart(3, "0")}.ts`
const before = reviewSource(index, lines)
const after = before
.replace(`value_${index}_4`, `updated_${index}_4`)
.replace(
`value_${index}_${Math.max(8, Math.floor(lines / 2))}`,
`updated_${index}_${Math.max(8, Math.floor(lines / 2))}`,
)
.replace(`value_${index}_${lines - 4}`, `updated_${index}_${lines - 4}`)
return {
file,
patch: reviewPatch(file, before, after),
additions: 3,
deletions: 3,
status: "modified" as const,
}
})
}
function reviewSource(seed: number, lines: number) {
return Array.from(
{ length: lines },
(_, index) => `export const value_${seed}_${index} = "${reviewWords(seed + index, index % 5 === 0 ? 180 : 42)}"`,
).join("\n")
}
function reviewPatch(file: string, before: string, after: string) {
const beforeLines = before.split("\n")
const afterLines = after.split("\n")
return [
`diff --git a/${file} b/${file}`,
`--- a/${file}`,
`+++ b/${file}`,
`@@ -1,${beforeLines.length} +1,${afterLines.length} @@`,
...beforeLines.flatMap((line, index) => {
const next = afterLines[index]!
if (line === next) return [` ${line}`]
return [`-${line}`, `+${next}`]
}),
].join("\n")
}
function reviewWords(seed: number, length: number) {
const words = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet"]
return Array.from({ length: Math.ceil(length / 7) }, (_, index) => words[(seed + index * 3) % words.length]).join(" ")
}
@@ -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,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,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,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")
}
@@ -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) {
@@ -128,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 [
@@ -163,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,
@@ -188,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,
@@ -197,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",
@@ -207,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,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,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}`
}
+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"
]
}
+9 -1
View File
@@ -1,7 +1,7 @@
import type { Page, Route } from "@playwright/test"
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"])
export interface MockServerConfig {
provider: unknown
@@ -17,6 +17,9 @@ export interface MockServerConfig {
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) {
@@ -53,7 +56,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
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])
+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
}
+3
View File
@@ -13,6 +13,7 @@
},
"scripts": {
"typecheck": "tsgo -b",
"typecheck:e2e": "tsgo -p e2e/tsconfig.json",
"start": "vite",
"dev": "vite",
"build": "vite build",
@@ -25,6 +26,7 @@
"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",
@@ -51,6 +53,7 @@
"@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:*",
+4 -1
View File
@@ -54,7 +54,7 @@ import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { SessionPage, TargetSessionRoute as TargetSessionRouteContent } from "@/pages/session"
import { SessionPage, TargetSessionRouteContent } from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home"
const NewSession = lazy(() => import("@/pages/new-session"))
@@ -100,6 +100,9 @@ const TargetSessionRoute = () => {
})
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}>
@@ -79,7 +79,6 @@ const ModelList: Component<{
class="w-full"
placement="right-start"
gutter={12}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
>
{node}
@@ -0,0 +1,421 @@
import { useFile } from "@/context/file"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import "@opencode-ai/ui/v2/file-tree-v2.css"
import {
createEffect,
createMemo,
For,
Match,
on,
Show,
splitProps,
Switch,
untrack,
type ComponentProps,
type ParentProps,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import {
dirsToExpand,
pathToFileUrl,
shouldListRoot,
visibleKind,
withFileDragImage,
type Filter,
type Kind,
} from "@/components/file-tree"
export type { Kind } from "@/components/file-tree"
const MAX_DEPTH = 128
function visibleNodesForPath(path: string, children: (dir: string) => FileNode[], current: Filter | undefined) {
const nodes = children(path)
if (!current) return nodes
const parent = (item: string) => {
const idx = item.lastIndexOf("/")
if (idx === -1) return ""
return item.slice(0, idx)
}
const leaf = (item: string) => {
const idx = item.lastIndexOf("/")
return idx === -1 ? item : item.slice(idx + 1)
}
const out = nodes.filter((node) => {
if (node.type === "file") return current.files.has(node.path)
return current.dirs.has(node.path)
})
const seen = new Set(out.map((node) => node.path))
for (const dir of current.dirs) {
if (parent(dir) !== path) continue
if (seen.has(dir)) continue
out.push({
name: leaf(dir),
path: dir,
absolute: dir,
type: "directory",
ignored: false,
})
seen.add(dir)
}
for (const item of current.files) {
if (parent(item) !== path) continue
if (seen.has(item)) continue
out.push({
name: leaf(item),
path: item,
absolute: item,
type: "file",
ignored: false,
})
seen.add(item)
}
out.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name)
})
return out
}
const INDENT_STEP = 16
function rowPaddingLeft(level: number, type: FileNode["type"]) {
if (type === "directory") return 8 + level * INDENT_STEP
if (level === 0) return 8
return 8 + level * INDENT_STEP - INDENT_STEP
}
function guideLineLeft(level: number) {
return rowPaddingLeft(level, "directory") + 8
}
export const kindLabel = (kind: Kind) => {
if (kind === "add") return "A"
if (kind === "del") return "D"
return ""
}
export const kindChange = (kind: Kind) => {
if (kind === "add") return "added"
if (kind === "del") return "deleted"
return "modified"
}
const FileTreeNodeV2 = (
p: ParentProps &
ComponentProps<"div"> &
ComponentProps<"button"> & {
node: FileNode
level: number
active?: string
draggable: boolean
kinds?: ReadonlyMap<string, Kind>
marks?: Set<string>
as?: "div" | "button"
},
) => {
const [local, rest] = splitProps(p, [
"node",
"level",
"active",
"draggable",
"kinds",
"marks",
"as",
"children",
"class",
"classList",
])
const kind = () => visibleKind(local.node, local.kinds, local.marks)
return (
<Dynamic
component={local.as ?? "div"}
data-slot="file-tree-v2-row"
data-selected={local.node.path === local.active ? "" : undefined}
data-ignored={local.node.ignored ? "" : undefined}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
style={`padding-left: ${rowPaddingLeft(local.level, local.node.type)}px`}
draggable={local.draggable}
onDragStart={(event: DragEvent) => {
if (!local.draggable) return
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
withFileDragImage(event)
}}
{...rest}
>
{local.children}
<span class="flex-1 min-w-0 text-12-medium whitespace-nowrap truncate">{local.node.name}</span>
{(() => {
const value = kind()
if (!value || local.node.type !== "file") return null
return (
<span data-slot="file-tree-v2-change" data-change={kindChange(value)}>
{kindLabel(value)}
</span>
)
})()}
</Dynamic>
)
}
// V2-styled fork of FileTree for the review sidebar. Unlike the v1 tree it never
// lists unloaded subdirectories, so callers must pass `allowed` (nodes are
// synthesized from that list) for nested content to appear.
export default function FileTreeV2(props: {
path: string
active?: string
level?: number
allowed?: readonly string[]
kinds?: ReadonlyMap<string, Kind>
draggable?: boolean
onFileClick?: (file: FileNode) => void
_filter?: Filter
_marks?: Set<string>
_deeps?: Map<string, number>
_kinds?: ReadonlyMap<string, Kind>
_chain?: readonly string[]
}) {
const file = useFile()
const level = props.level ?? 0
const draggable = () => props.draggable ?? true
const key = (p: string) =>
file
.normalize(p)
.replace(/[\\/]+$/, "")
.replaceAll("\\", "/")
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
const filter = createMemo(() => {
if (props._filter) return props._filter
const allowed = props.allowed
if (!allowed) return
const files = new Set(allowed)
const dirs = new Set<string>()
for (const item of allowed) {
const parts = item.split("/")
const parents = parts.slice(0, -1)
for (const [idx] of parents.entries()) {
const dir = parents.slice(0, idx + 1).join("/")
if (dir) dirs.add(dir)
}
}
return { files, dirs }
})
const marks = createMemo(() => {
if (props._marks) return props._marks
const out = new Set<string>(props.kinds?.keys() ?? [])
if (out.size === 0) return
return out
})
const kinds = createMemo(() => {
if (props._kinds) return props._kinds
return props.kinds
})
const deeps = createMemo(() => {
if (props._deeps) return props._deeps
const out = new Map<string, number>()
const root = props.path
if (!(file.tree.state(root)?.expanded ?? false)) return out
const seen = new Set<string>()
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
const push = (dir: string, lvl: number) => {
const id = key(dir)
if (seen.has(id)) return
seen.add(id)
const kids = file.tree
.children(dir)
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
.map((node) => node.path)
stack.push({ dir, lvl, i: 0, kids, max: lvl })
}
push(root, level - 1)
while (stack.length > 0) {
const top = stack[stack.length - 1]!
if (top.i < top.kids.length) {
const next = top.kids[top.i]!
top.i++
push(next, top.lvl + 1)
continue
}
out.set(top.dir, top.max)
stack.pop()
const parent = stack[stack.length - 1]
if (!parent) continue
parent.max = Math.max(parent.max, top.max)
}
return out
})
createEffect(() => {
const current = filter()
const dirs = dirsToExpand({
level,
filter: current,
expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
})
// Nodes come from the `allowed` filter; skip listing so directories that only
// exist on the diff's base branch do not each fail with an error toast.
for (const dir of dirs) file.tree.expand(dir, { list: false })
})
createEffect(
on(
() => props.path,
(path) => {
const dir = untrack(() => file.tree.state(path))
if (!shouldListRoot({ level, dir })) return
void file.tree.list(path)
},
{ defer: false },
),
)
const nodes = createMemo(() => visibleNodesForPath(props.path, file.tree.children, filter()))
return (
// group/file-tree-v2 scopes the group-hover guide lines below; hosts may add
// an outer group with the same name to widen the hover area.
<div data-component="file-tree-v2" class="group/file-tree-v2">
<For each={nodes()}>
{(node) => {
const expanded = () => file.tree.state(node.path)?.expanded ?? false
const deep = () => deeps().get(node.path) ?? -1
const hasChildren = () => visibleNodesForPath(node.path, file.tree.children, filter()).length > 0
return (
<Switch>
<Match when={node.type === "directory"}>
<Collapsible
variant="ghost"
class="w-full"
data-scope="file-tree-v2"
forceMount={false}
open={expanded()}
onOpenChange={(open) =>
open ? file.tree.expand(node.path, { list: false }) : file.tree.collapse(node.path)
}
>
<Collapsible.Trigger>
<FileTreeNodeV2
node={node}
level={level}
active={props.active}
draggable={draggable()}
kinds={kinds()}
marks={marks()}
>
<div
data-slot="file-tree-v2-chevron"
data-expanded={expanded() ? "" : undefined}
class="size-4 flex items-center justify-center"
>
<Icon name="chevron-down" />
</div>
</FileTreeNodeV2>
</Collapsible.Trigger>
<Show when={hasChildren()}>
<Collapsible.Content class="relative">
<div
classList={{
"absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 transition-opacity duration-150 ease-out motion-reduce:transition-none": true,
"group-hover/file-tree-v2:opacity-100": expanded() && deep() === level,
"group-hover/file-tree-v2:opacity-50": !(expanded() && deep() === level),
}}
style={`left: ${guideLineLeft(level)}px`}
/>
<Show
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
>
<FileTreeV2
path={node.path}
level={level + 1}
allowed={props.allowed}
kinds={props.kinds}
active={props.active}
draggable={props.draggable}
onFileClick={props.onFileClick}
_filter={filter()}
_marks={marks()}
_deeps={deeps()}
_kinds={kinds()}
_chain={chain}
/>
</Show>
</Collapsible.Content>
</Show>
</Collapsible>
</Match>
<Match when={node.type === "file"}>
<FileTreeNodeV2
node={node}
level={level}
active={props.active}
draggable={draggable()}
kinds={kinds()}
marks={marks()}
as="button"
type="button"
onClick={() => props.onFileClick?.(node)}
>
<Show when={level > 0}>
<div class="w-4 shrink-0" />
</Show>
<Show
when={!node.ignored}
fallback={<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />}
>
<span class="filetree-iconpair size-4">
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</Show>
</FileTreeNodeV2>
</Match>
</Switch>
)
}}
</For>
</div>
)
}
+5 -5
View File
@@ -21,13 +21,13 @@ import type { FileNode } from "@opencode-ai/sdk/v2"
const MAX_DEPTH = 128
function pathToFileUrl(filepath: string): string {
export function pathToFileUrl(filepath: string): string {
return `file://${encodeFilePath(filepath)}`
}
type Kind = "add" | "del" | "mix"
export type Kind = "add" | "del" | "mix"
type Filter = {
export type Filter = {
files: Set<string>
dirs: Set<string>
}
@@ -78,7 +78,7 @@ const kindDotColor = (kind: Kind) => {
return "background-color: var(--icon-diff-modified-base)"
}
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
export const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
const kind = kinds?.get(node.path)
if (!kind) return
if (!marks?.has(node.path)) return
@@ -99,7 +99,7 @@ const buildDragImage = (target: HTMLElement) => {
return image
}
const withFileDragImage = (event: DragEvent) => {
export const withFileDragImage = (event: DragEvent) => {
const image = buildDragImage(event.currentTarget as HTMLElement)
if (!image) return
document.body.appendChild(image)
@@ -7,6 +7,7 @@ export function createPromptSubmissionState(input: {
prompt: Prompt
context: (ContextItem & { key: string })[]
}) {
const initial = input.target
let target = input.target
let cleared: Prompt | undefined
@@ -15,6 +16,7 @@ export function createPromptSubmissionState(input: {
context: input.context,
target: () => target,
clear() {
if (initial !== target) initial.reset()
target.reset()
cleared = target.current()
},
@@ -28,7 +28,6 @@ export function TabNavItem(props: {
onClose: () => void
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
suppressNavigation?: () => boolean
dragging?: boolean
@@ -71,11 +70,6 @@ export function TabNavItem(props: {
const home = serverCtx()?.sync.data.path.home
return home ? session.directory.replace(home, "~") : session.directory
})
const branch = createMemo(() => {
const session = props.session()
if (!session) return
return serverCtx()?.sync.child(session.directory, { bootstrap: false })[0].vcs?.branch
})
// Only label the server when multiple servers are connected.
const serverLabel = createMemo(() => {
if (global.servers.list().length <= 1) return
@@ -235,8 +229,17 @@ export function TabNavItem(props: {
event.preventDefault()
event.stopPropagation()
}}
onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
onClick={(event) => {
event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
@@ -250,7 +253,7 @@ export function TabNavItem(props: {
project={project()}
directory={session().directory}
sessionId={session().id}
activeServer={props.activeServer}
server={props.server}
/>
</span>
)}
@@ -322,7 +325,6 @@ export function TabNavItem(props: {
projectName: projectName(),
title: props.session()?.title,
path: previewPath(),
branch: branch(),
serverName: serverLabel(),
}}
/>
@@ -375,8 +377,16 @@ export function DraftTabItem(props: {
event.preventDefault()
event.stopPropagation()
}}
onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
onClick={(event) => {
event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
@@ -10,7 +10,7 @@
width: 256px;
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
padding: 12px;
background: var(--v2-background-bg-base);
@@ -23,30 +23,37 @@
pointer-events: none;
transform-origin: var(--kb-hovercard-content-transform-origin);
/* Entrance: fade in while scaling up from 0.96 to 1.0. */
animation: sessionTabPopoverIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
/* Entrance: instant (trying no animation — may revert to 120ms ease-out). */
animation: none;
&:focus-visible,
&:focus-within {
outline: none;
}
/* Exit: subtle fade + scale down, using the same ease-out curve as entry. */
/* Exit: instant (trying no animation — may revert to 80ms ease-out). */
&[data-closed] {
animation: sessionTabPopoverOut 100ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
animation: none;
}
/* Warm streak (rapid tab hopping): appear/disappear instantly so the enter
and exit animations don't repeat on every tab. */
&[data-instant],
&[data-instant][data-closed] {
animation: none;
}
[data-slot="header"] {
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
width: 100%;
}
[data-slot="project"] {
font-weight: 440;
font-size: 13px;
line-height: 1;
line-height: 1.5;
letter-spacing: -0.04px;
color: var(--v2-text-text-muted);
}
@@ -54,7 +61,7 @@
[data-slot="title"] {
font-weight: 530;
font-size: 13px;
line-height: 1.2;
line-height: 1.5;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
overflow-wrap: anywhere;
@@ -62,31 +69,18 @@
[data-slot="row"] {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
}
[data-slot="icon"] {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 16px;
height: 16px;
color: var(--v2-icon-icon-muted);
}
[data-slot="detail"] {
flex: 1 0 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* Wrap the full path onto the next line instead of clipping the end. */
overflow-wrap: anywhere;
font-weight: 440;
font-size: 13px;
line-height: 1;
line-height: 1.5;
letter-spacing: -0.04px;
color: var(--v2-text-text-muted);
}
@@ -1,6 +1,5 @@
import { HoverCard as Kobalte } from "@kobalte/core/hover-card"
import { Show, type JSXElement } from "solid-js"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { createSignal, Show, type JSXElement } from "solid-js"
import "./titlebar-tab-popover.css"
// Initial hover delay before the preview appears, per design.
@@ -10,14 +9,13 @@ const CLOSE_DELAY = 0
// After a preview closes, hovering a neighbouring tab within this window skips
// the open delay — mirrors the tooltip's skipDelayDuration so moving across
// tabs doesn't re-wait the full delay each time.
const SKIP_WINDOW = 300
const SKIP_WINDOW = 500
let lastClosedAt = 0
export interface TabPreviewData {
projectName?: string
title?: string
path?: string
branch?: string
serverName?: string
}
@@ -28,12 +26,18 @@ export function TabPreviewPopover(props: {
data: TabPreviewData
}) {
let triggerEl: HTMLDivElement | undefined
// When opened during a rapid tab-hopping streak, this preview appears and
// disappears instantly (no repeated enter/exit animation) — only the first,
// "cold" preview animates. Mirrors how browsers reuse one tab tooltip.
const [instant, setInstant] = createSignal(false)
const warm = () => Date.now() - lastClosedAt < SKIP_WINDOW
// Kobalte reads openDelay lazily when the pointer enters the trigger, so this
// resolves the skip window per-hover.
const resolveOpenDelay = () => (Date.now() - lastClosedAt < SKIP_WINDOW ? 0 : OPEN_DELAY)
const resolveOpenDelay = () => (warm() ? 0 : OPEN_DELAY)
const handleOpenChange = (open: boolean) => {
if (!open) lastClosedAt = Date.now()
if (open) setInstant(warm())
else lastClosedAt = Date.now()
props.onOpenChange(open)
}
@@ -61,6 +65,7 @@ export function TabPreviewPopover(props: {
if (theme) el.setAttribute("data-theme", theme)
}}
data-component="session-tab-popover"
data-instant={instant() || undefined}
>
<div data-slot="header">
<Show when={props.data.projectName}>
@@ -73,22 +78,10 @@ export function TabPreviewPopover(props: {
<Show when={props.data.path}>
<div data-slot="row">
<span data-slot="icon">
<IconV2 name="folder" />
</span>
<span data-slot="detail">{props.data.path}</span>
</div>
</Show>
<Show when={props.data.branch}>
<div data-slot="row">
<span data-slot="icon">
<IconV2 name="branch" />
</span>
<span data-slot="detail">{props.data.branch}</span>
</div>
</Show>
<Show when={props.data.serverName}>
<div data-slot="server">{props.data.serverName}</div>
</Show>
@@ -24,7 +24,6 @@ function SessionTabSlot(props: {
id: string
index: () => number
active: () => boolean
activeServerKey: ServerConnection.Key
forceTruncate: boolean
serverCtx: () => ServerCtx | undefined
onNavigate: (element: HTMLDivElement) => void
@@ -112,7 +111,6 @@ function SessionTabSlot(props: {
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
activeServer={props.tab.server === props.activeServerKey}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
/>
@@ -165,7 +163,6 @@ function DraftTabSlot(props: {
export function TitlebarTabStrip(props: {
tabs: Tab[]
currentTab: () => Tab | undefined
activeServerKey: ServerConnection.Key
forceTruncate: boolean
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
onClose: (tab: Tab) => void
@@ -271,7 +268,6 @@ export function TitlebarTabStrip(props: {
id={id}
index={index}
active={() => props.currentTab() === tab}
activeServerKey={props.activeServerKey}
forceTruncate={props.forceTruncate}
serverCtx={serverCtx}
onNavigate={(element) => {
-1
View File
@@ -456,7 +456,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<TitlebarTabStrip
tabs={tabsStore}
currentTab={currentTab}
activeServerKey={server.key}
forceTruncate={tabsAreOverflowing()}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
+9 -1
View File
@@ -85,7 +85,15 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
active: null as CommentFocus | null,
})
const all = () => aggregate(store.comments)
// Reuse the previous array when contents are unchanged so consumers keep a stable
// identity; a fresh array per call cascaded into diff annotation re-renders.
let lastAll: LineComment[] = []
const all = () => {
const next = aggregate(store.comments)
if (next.length === lastAll.length && next.every((item, index) => item === lastAll[index])) return lastAll
lastAll = next
return next
}
const setRef = (
key: "focus" | "active",
+5 -1
View File
@@ -127,10 +127,14 @@ export function createFileTreeStore(options: TreeStoreOptions) {
return promise
}
const expandDir = (input: string) => {
// `list: false` marks a directory expanded without fetching its children, for
// trees whose nodes are synthesized from a filter; listing directories that
// only exist on a diff's base branch fails and surfaces error toasts.
const expandDir = (input: string, behavior?: { list?: boolean }) => {
const dir = options.normalizeDir(input)
ensureDir(dir)
setTree("dir", dir, "expanded", true)
if (behavior?.list === false) return
void listDir(dir)
}
@@ -1,50 +1,55 @@
import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query"
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
import type { State, VcsCache } from "./types"
import { createServerSession } from "../server-session"
import { ServerScope } from "@/utils/server-scope"
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
function directoryState() {
return createStore<State>({
status: "loading",
agent: [],
command: [],
reference: [],
project: "",
projectMeta: undefined,
icon: undefined,
provider_ready: true,
provider,
config: {},
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
session: [],
sessionTotal: 0,
session_status: {},
session_working(id: string) {
return this.session_status[id]?.type !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp_ready: true,
mcp: {},
mcp_resource: {},
lsp_ready: true,
lsp: [],
vcs: undefined,
limit: 5,
message: {},
part: {},
part_text_accum_delta: {},
})
}
describe("bootstrapDirectory", () => {
test("marks a loading directory partial during bootstrap and complete after success", async () => {
const mcpReads: string[] = []
const [store, setStore] = createStore<State>({
status: "loading",
agent: [],
command: [],
reference: [],
project: "",
projectMeta: undefined,
icon: undefined,
provider_ready: true,
provider,
config: {},
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
session: [],
sessionTotal: 0,
session_status: {},
session_working(id: string) {
return this.session_status[id]?.type !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp_ready: true,
mcp: {},
mcp_resource: {},
lsp_ready: true,
lsp: [],
vcs: undefined,
limit: 5,
message: {},
part: {},
part_text_accum_delta: {},
})
const [store, setStore] = directoryState()
await bootstrapDirectory({
directory: "/project",
@@ -93,6 +98,66 @@ describe("bootstrapDirectory", () => {
expect(store.status).toBe("complete")
expect(mcpReads).toEqual([])
})
test("seeds session status even while warming session info stalls", async () => {
const [store, setStore] = directoryState()
const stalled = Promise.withResolvers<never>()
const client = {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { get: async () => ({ data: {} }) },
session: {
status: async () => ({ data: { ses_busy: { type: "busy" } } }),
get: () => stalled.promise,
},
vcs: { get: async () => ({ data: undefined }) },
command: { list: async () => ({ data: [] }) },
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
mcp: { status: async () => ({ data: {} }) },
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient
const session = createServerSession(client)
const stale: Session = {
id: "ses_stale",
slug: "ses_stale",
projectID: "project",
directory: "/project",
title: "stale",
version: "1",
time: { created: 1, updated: 1 },
}
session.remember(stale)
session.set("session_status", stale.id, { type: "busy" })
await bootstrapDirectory({
directory: "/project",
scope: ServerScope.local,
mcp: false,
global: {
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
project: [{ id: "project", worktree: "/project" } as Project],
provider,
},
sdk: client,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
loadSessions() {},
translate: (key) => key,
queryClient: new QueryClient(),
session,
})
const deadline = Date.now() + 500
while (!session.data.session_working("ses_busy") && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10))
}
expect(session.data.session_status["ses_busy"]?.type).toBe("busy")
expect(session.data.session_status[stale.id]).toBeUndefined()
})
})
describe("query keys", () => {
@@ -247,25 +247,29 @@ export async function bootstrapDirectory(input: {
() =>
retry(() =>
input.sdk.session.status().then(async (x) => {
if (input.session) {
const statuses = x.data ?? {}
await Promise.all(
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
)
input.session.set(
"session_status",
produce((draft) => {
for (const sessionID of Object.keys(draft)) {
if (statuses[sessionID]) continue
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
}
}),
)
for (const [sessionID, status] of Object.entries(statuses)) {
input.session.set("session_status", sessionID, reconcile(status))
}
if (!input.session) {
input.setStore("session_status", x.data!)
return
}
if (!input.session) input.setStore("session_status", x.data!)
const statuses = x.data ?? {}
input.session.set(
"session_status",
produce((draft) => {
for (const sessionID of Object.keys(draft)) {
if (statuses[sessionID]) continue
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
}
}),
)
for (const [sessionID, status] of Object.entries(statuses)) {
input.session.set("session_status", sessionID, reconcile(status))
}
// Warm session info only after seeding statuses so a stalled session
// fetch cannot park busy indicators behind it, mirroring how live
// session.status events apply first and resolve info in the background.
await Promise.all(
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
)
}),
),
!seededProject &&
+2 -1
View File
@@ -14,6 +14,7 @@ import type {
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
import { sessionNotFoundError } from "@/utils/server-errors"
import { rootSession } from "@/utils/session-route"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
@@ -235,7 +236,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
if (pending) return pending
const active = generation(sessionID)
const request = client.session.get({ sessionID }).then((result) => {
if (!result.data) throw new Error(`Session not found: ${sessionID}`)
if (!result.data) throw sessionNotFoundError(sessionID)
if (generations.get(sessionID) !== active) return result.data
return remember(result.data)
})
+1 -10
View File
@@ -552,7 +552,6 @@ export function NewHome() {
results={searchResults()}
showProjectName={!selectedProject()}
server={selection().server}
activeServer={selection().server === server.key}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
bindFocus={(focus) => {
focusSessionSearch = focus
@@ -612,7 +611,6 @@ export function NewHome() {
record={record}
showProjectName={!selectedProject()}
server={selection().server}
activeServer={selection().server === server.key}
openSession={openSession}
archiveSession={archiveSession}
/>
@@ -996,7 +994,6 @@ function HomeSessionLeading(props: {
project: LocalProject
session: Session
server: ServerConnection.Key
activeServer: boolean
revealProjectOnHover: boolean
}) {
const tabs = useTabs()
@@ -1014,7 +1011,7 @@ function HomeSessionLeading(props: {
project={props.project}
directory={props.session.directory}
sessionId={props.session.id}
activeServer={props.activeServer}
server={props.server}
revealProjectOnHover={props.revealProjectOnHover}
/>
</div>
@@ -1029,7 +1026,6 @@ function HomeSessionSearch(props: {
results: HomeSessionRecord[]
showProjectName: boolean
server: ServerConnection.Key
activeServer: boolean
noResultsLabel: string
bindFocus: (focus: () => void) => void
onInput: (value: string) => void
@@ -1147,7 +1143,6 @@ function HomeSessionSearch(props: {
record={record}
showProjectName={props.showProjectName}
server={props.server}
activeServer={props.activeServer}
selected={store.active === homeSessionSearchKey(record)}
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
onSelect={(session) => props.onSelect(session)}
@@ -1228,7 +1223,6 @@ function HomeSessionSearchResultRow(props: {
record: HomeSessionRecord
showProjectName: boolean
server: ServerConnection.Key
activeServer: boolean
selected: boolean
onHighlight: () => void
onSelect: (session: Session) => void
@@ -1258,7 +1252,6 @@ function HomeSessionSearchResultRow(props: {
project={props.record.project}
session={props.record.session}
server={props.server}
activeServer={props.activeServer}
revealProjectOnHover={!!showProjectName()}
/>
<div class="flex min-w-0 flex-1 items-center gap-1.5">
@@ -1297,7 +1290,6 @@ function HomeSessionRow(props: {
record: HomeSessionRecord
showProjectName: boolean
server: ServerConnection.Key
activeServer: boolean
openSession: (session: Session) => void
archiveSession: (session: Session) => Promise<void>
}) {
@@ -1320,7 +1312,6 @@ function HomeSessionRow(props: {
project={props.record.project}
session={props.record.session}
server={props.server}
activeServer={props.activeServer}
revealProjectOnHover={!!showProjectName()}
/>
<span
@@ -1,29 +1,41 @@
import { createMemo, type Accessor } from "solid-js"
import { useServerSync } from "@/context/server-sync"
import { useGlobal } from "@/context/global"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { ServerConnection } from "@/context/server"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
export function useSessionTabAvatarState(
server: Accessor<ServerConnection.Key>,
directory: Accessor<string>,
sessionId: Accessor<string>,
active: Accessor<boolean> = () => true,
) {
const globalSync = useServerSync()
const global = useGlobal()
const notification = useNotification()
const permission = usePermission()
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
const sync = createMemo(() => {
const conn = connection()
if (conn) return global.ensureServerCtx(conn).sync
})
const hasPermissions = createMemo(() => {
if (!active()) return false
const [store] = globalSync().child(directory(), { bootstrap: false })
return !!sessionPermissionRequest(store.session, globalSync().session.data.permission, sessionId(), (item) => {
const serverSync = sync()
if (!serverSync) return false
const [store] = serverSync.child(directory(), { bootstrap: false })
return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
return !permission.autoResponds(item, directory())
})
})
const unread = createMemo(() => active() && (hasPermissions() || notification.session.unseenCount(sessionId()) > 0))
const unread = createMemo(() => {
if (hasPermissions()) return true
if (!connection()) return false
return notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0
})
const loading = createMemo(() => {
if (!active()) return false
const serverSync = sync()
if (!serverSync) return false
if (hasPermissions()) return false
return globalSync().session.data.session_working(sessionId())
return serverSync.session.data.session_working(sessionId())
})
return { unread, loading }
}
@@ -1,5 +1,6 @@
import type { LocalProject } from "@/context/layout"
import { getProjectAvatarVariant } from "@/context/layout"
import type { ServerConnection } from "@/context/server"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
@@ -10,12 +11,14 @@ export function SessionTabAvatar(props: {
project?: LocalProject
directory: string
sessionId: string
activeServer: boolean
server: ServerConnection.Key
revealProjectOnHover?: boolean
}) {
const directory = () => props.directory
const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
const state = useSessionTabAvatarState(
() => props.server,
() => props.directory,
() => props.sessionId,
)
const projectAvatar = () => (
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
+143 -39
View File
@@ -11,7 +11,6 @@ import {
createMemo,
createEffect,
createComputed,
createResource,
on,
onMount,
type ParentProps,
@@ -24,8 +23,11 @@ import { debounce } from "@solid-primitives/scheduled"
import { useLocal } from "@/context/local"
import { FileProvider, selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore } from "solid-js/store"
import type { SessionReviewLineComment } from "@opencode-ai/session-ui/session-review"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
@@ -76,6 +78,10 @@ import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/
import { useSessionLayout } from "@/pages/session/session-layout"
import { syncSessionModel } from "@/pages/session/session-model-helpers"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { useSessionCommands } from "@/pages/session/use-session-commands"
@@ -84,10 +90,11 @@ import { Identifier } from "@/utils/id"
import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt"
import { formatServerError, isSessionNotFoundError } from "@/utils/server-errors"
import { legacySessionHref, requireServerKey, selectSessionLineage, sessionHref } from "@/utils/session-route"
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
import { createSessionOwnership } from "./session/session-ownership"
import { createSessionLineage } from "./session/session-lineage"
type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
@@ -102,10 +109,6 @@ const sessionViewState = () => ({
changes: "git" as ChangeMode,
})
function isLocalSessionNotFoundError(error: unknown, sessionID: string) {
return error instanceof Error && error.message === `Session not found: ${sessionID}`
}
function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) {
if (!sessionID) return false
return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID)
@@ -142,14 +145,16 @@ export function SessionPage() {
)
}
export function TargetSessionRoute() {
// Rendered under app.tsx's TargetSessionRoute, which owns the per-server keyed
// remount around the server-scoped providers. Nothing here may key on the
// session ID: session tabs on the same server share this route instance, and
// workspace-scoped state (terminal, directory providers) lives below.
export function TargetSessionRouteContent() {
const params = useParams<{ serverKey: string; id: string }>()
return (
<Show when={`${params.serverKey}\0${params.id}`} keyed>
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<ResolvedTargetSessionRoute />
</SessionRouteErrorBoundary>
</Show>
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<ResolvedTargetSessionRoute />
</SessionRouteErrorBoundary>
)
}
@@ -225,15 +230,10 @@ function ResolvedTargetSessionRoute() {
const tabs = useTabs()
const sync = useServerSync()
const serverKey = createMemo(() => requireServerKey(params.serverKey))
const cached = createMemo(() => sync().session.lineage.peek(params.id))
const [resolved] = createResource(
() => {
if (cached()) return
return { id: params.id, sync: sync() }
},
({ id, sync }) => sync.session.lineage.resolve(id),
const current = createSessionLineage(
() => params.id,
() => sync().session.lineage,
)
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
const directory = createMemo(() => current()?.session.directory)
const targetDirectory = () => directory()!
@@ -248,6 +248,10 @@ function ResolvedTargetSessionRoute() {
return (
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
{/* Non-keyed: closes only while the target's directory is unknown (uncached
lineage mid-resolution), which tears down the workspace subtree including
the terminal. Same-workspace tab switches keep it open because warm
targets resolve synchronously from the sync cache. */}
<Show when={directory()}>
<Show
when={settings.general.newLayoutDesigns()}
@@ -264,6 +268,9 @@ function ResolvedTargetSessionRoute() {
)
}
// Owns the workspace-identity remount. Must not include the session ID in the
// key: SessionPage handles session changes reactively, and remounting here
// destroys workspace-scoped state (terminal PTYs, file/prompt providers).
function TargetSessionPage() {
const sdk = useSDK()
const serverSDK = useServerSDK()
@@ -385,6 +392,7 @@ export default function Page() {
})
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
const sessionPanelKey = createMemo(() => (params.id ? `${serverSDK().scope}\0${params.id}` : undefined))
createEffect(
on(
@@ -940,9 +948,11 @@ export default function Page() {
if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return
}
// Only treat explicit scroll keys as potential "user scroll" gestures.
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
markScrollGesture()
const key = scrollKey(event)
if (key) {
if (!scroller || !isScrollKeyTarget(target ?? null, key)) return
if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return
markScrollGesture(scroller)
return
}
@@ -1022,22 +1032,22 @@ export default function Page() {
loadFile: file.load,
})
const changesLabel = (option: ChangeMode) => {
if (option === "git") return language.t("ui.sessionReview.title.git")
if (option === "branch") return language.t("ui.sessionReview.title.branch")
return language.t("ui.sessionReview.title.lastTurn")
}
const changesTitle = () => {
if (!canReview()) {
return null
}
const label = (option: ChangeMode) => {
if (option === "git") return language.t("ui.sessionReview.title.git")
if (option === "branch") return language.t("ui.sessionReview.title.branch")
return language.t("ui.sessionReview.title.lastTurn")
}
return (
<Select
options={changesOptions()}
current={store.changes}
label={label}
label={changesLabel}
onSelect={(option) => option && setStore("changes", option)}
variant="ghost"
size="small"
@@ -1046,6 +1056,24 @@ export default function Page() {
)
}
const changesTitleV2 = () => {
if (!canReview()) {
return null
}
return (
<SelectV2
appearance="inline"
options={changesOptions()}
current={store.changes}
label={changesLabel}
placement="bottom-start"
gutter={6}
onSelect={(option) => option && setStore("changes", option)}
/>
)
}
const empty = (text: string) => (
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
@@ -1092,6 +1120,16 @@ export default function Page() {
)
}
const reviewEmptyV2 = () => {
if ((store.changes === "git" || store.changes === "branch") && !reviewReady()) {
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
}
if (store.changes === "turn" && nogit()) {
return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
}
return <SessionReviewEmptyChangesV2 />
}
const reviewContent = (input: {
diffStyle: DiffStyle
onDiffStyleChange?: (style: DiffStyle) => void
@@ -1125,6 +1163,66 @@ export default function Page() {
</Show>
)
const reviewV2State = createReviewPanelV2State()
// Getters defer reactive reads to the consuming scope. Eager reads here ran inside
// the side panel's Show children and remounted the whole review panel on unrelated
// updates such as session switches.
const reviewPanelV2Props = () => ({
get title() {
return changesTitleV2()
},
get empty() {
return reviewEmptyV2()
},
diffs: reviewDiffs,
diffsReady: reviewReady,
get activeFile() {
return tree.activeDiff
},
onSelectFile: focusReviewDiff,
get diffStyle() {
return layout.review.diffStyle()
},
onDiffStyleChange: layout.review.setDiffStyle,
state: reviewV2State,
onLineComment: (comment: SessionReviewLineComment) => addCommentToContext({ ...comment, origin: "review" }),
onLineCommentUpdate: updateCommentInContext,
onLineCommentDelete: removeCommentFromContext,
get lineCommentActions() {
return reviewCommentActions()
},
get comments() {
return comments.all()
},
get focusedComment() {
return comments.focus()
},
onFocusedCommentChange: (focus: { file: string; id: string } | null) => {
// The preview clears the focus once it has opened the comment; persist the
// focused file as the active selection so the preview stays on it. Skip
// files outside the current diff set (their focus is cleared unhandled).
if (!focus) {
const current = comments.focus()
if (current && reviewDiffs().some((diff) => diff.file === current.file)) focusReviewDiff(current.file)
}
comments.setFocus(focus)
},
})
// Latch: defer only the first diff render off the mount critical path. This Page
// stays mounted across same-workspace session tab switches, so gating on every
// deferRender flip tore down and remounted the whole review pane on tab switch.
const reviewPanelV2Rendered = createMemo<boolean>((prev) => prev || !store.deferRender, false)
const reviewPanelV2 = () => (
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
<Show when={reviewPanelV2Rendered()}>
<ReviewPanelV2 {...reviewPanelV2Props()} />
</Show>
</div>
)
const reviewPanel = () => (
<div
classList={{
@@ -2014,13 +2112,19 @@ export default function Page() {
width: sessionPanelWidth(),
}}
>
<SessionPanelFrame newLayout={settings.general.newLayoutDesigns()} raised={!!params.id}>
{settings.general.newLayoutDesigns() ? (
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
) : (
sessionPanelContent()
)}
</SessionPanelFrame>
{settings.general.newLayoutDesigns() ? (
<Show when={sessionPanelKey()} keyed>
{(_) => (
<SessionPanelFrame newLayout raised={!!params.id}>
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
</SessionPanelFrame>
)}
</Show>
) : (
<SessionPanelFrame newLayout={false} raised={!!params.id}>
{sessionPanelContent()}
</SessionPanelFrame>
)}
<Show when={desktopReviewOpen()}>
<div onPointerDown={() => size.start()}>
@@ -2048,7 +2152,7 @@ export default function Page() {
empty={reviewEmptyText}
hasReview={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel}
reviewPanel={() => (newSessionDesign() ? reviewPanelV2() : reviewPanel())}
activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
@@ -0,0 +1,69 @@
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { sessionNotFoundError } from "@/utils/server-errors"
type LineageStore<T> = { peek: (id: string) => T | undefined; resolve: (id: string) => Promise<unknown> }
type Resolution<T> = { id: string; store: LineageStore<T> } & (
| { state: "pending" }
| { state: "settled" }
| { state: "failed"; failure: unknown }
)
// Reactive session lineage for the target session route, read from the sync store.
// All session tabs on a server share one route instance, so the target session ID
// changes in place; the effect is only a trigger that starts resolution for the
// current target, and each run cancels the previous one through onCleanup so a
// late result from an abandoned target is dropped. Resolution is imperative rather
// than a resource on purpose: a resource created here would be created inside the
// router's navigation transition, and suspending that transition deadlocks the URL
// commit and double-mounts the session header portals from the transition's shadow
// render.
//
// The returned accessor is a pure derivation. The sync cache is authoritative, and
// status only applies while it matches the current target (store + session ID): on
// navigation or store replacement the memo re-evaluates before the trigger runs,
// so trusting a previous target's settlement would fabricate a not-found for a
// session that simply has not resolved yet. Resolve failures rethrow on read so
// the enclosing SessionRouteErrorBoundary renders the scoped session error.
export function createSessionLineage<T>(sessionID: () => string, lineage: () => LineageStore<T>) {
const cached = createMemo(() => lineage().peek(sessionID()))
const [status, setStatus] = createSignal<Resolution<T>>()
createEffect(
on([sessionID, lineage] as const, ([id, store]) => {
let stale = false
onCleanup(() => {
stale = true
})
if (cached()) {
setStatus({ id, store, state: "settled" })
return
}
setStatus({ id, store, state: "pending" })
store
.resolve(id)
.then(() => {
if (!stale) setStatus({ id, store, state: "settled" })
})
.catch((failure) => {
if (!stale) setStatus({ id, store, state: "failed", failure })
})
}),
)
return createMemo(() => {
const id = sessionID()
const value = cached()
if (value) return value
const state = status()
if (state?.id !== id || state.store !== lineage()) return undefined
if (state.state === "failed") throw state.failure
// The viewed session is pinned (DirectoryDataProvider, directory-layout.tsx)
// and pinned lineages are exempt from cache pruning, so a lineage missing
// after settlement means the session (or an ancestor) was deleted, possibly
// by another client. Match the resolve error so the boundary shows the
// session not found fallback.
if (state.state === "settled") throw sessionNotFoundError(id)
return undefined
})
}
@@ -40,7 +40,7 @@ import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencod
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { TextField } from "@opencode-ai/ui/text-field"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
@@ -441,6 +441,16 @@ export function MessageTimeline(props: {
},
})
const resizeItem = virtualizer.resizeItem
let resizeAnchorScheduled = false
const anchorResizedBottom = () => {
if (resizeAnchorScheduled || props.hasScrollGesture()) return
resizeAnchorScheduled = true
queueMicrotask(() => {
resizeAnchorScheduled = false
if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
virtualizer.scrollToEnd()
})
}
virtualizer.resizeItem = (index, size) => {
const item = virtualizer.measurementsCache[index]
const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined
@@ -462,9 +472,13 @@ export function MessageTimeline(props: {
})
}
resizeItem(index, size)
if (root && props.shouldAnchorBottom()) anchorResizedBottom()
}
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => {
if (props.shouldAnchorBottom()) return false
const first = virtualizer.range?.startIndex
return first !== undefined && item.index < first
}
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
item.end <= instance.getLogicalScrollOffset()
const virtualItemByKey = createMemo(
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
)
@@ -491,24 +505,13 @@ export function MessageTimeline(props: {
})
})
let bottomAnchorSessionKey = ""
let bottomAnchorFrame: number | undefined
const maybeAnchorBottom = () => {
const key = sessionKey()
if (bottomAnchorSessionKey === key) return
if (timelineRows().length === 0) return
bottomAnchorSessionKey = key
if (!props.shouldAnchorBottom()) return
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
clearPrependAnchor()
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
bottomAnchorFrame = requestAnimationFrame(() => {
bottomAnchorFrame = undefined
if (sessionKey() !== key) return
virtualizer.scrollToEnd()
})
virtualizer.scrollToEnd()
}
let measuredSessionKey = sessionKey()
@@ -527,7 +530,6 @@ export function MessageTimeline(props: {
timelineCache.delete(ownerSessionKey)
timelineCache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
while (timelineCache.size > 16) timelineCache.delete(timelineCache.keys().next().value!)
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
props.setRevealMessage?.(() => {})
@@ -600,6 +602,15 @@ export function MessageTimeline(props: {
props.onMarkScrollGesture(event.currentTarget)
}
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
const key = scrollKey(event)
if (!key) return
if (!isScrollKeyTarget(event.target, key)) return
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
if (!prependLoading) clearPrependAnchor()
props.onMarkScrollGesture(event.currentTarget)
}
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (prependLoading) updatePrependAnchor()
props.onScheduleScrollState(event.currentTarget)
@@ -976,10 +987,16 @@ export function MessageTimeline(props: {
.map((ref) => getMsgPart(ref.messageID, ref.partID))
.filter((part): part is ToolPart => part?.type === "tool")
})
const contextOpenKey = () => `context:${row().group.key}`
const open = createMemo(() => {
return toolOpen[contextOpenKey()] === true
})
return (
<ContextToolGroup
parts={parts()}
open={open()}
onOpenChange={(value) => setToolOpen(contextOpenKey(), value)}
busy={
workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key
}
@@ -1339,6 +1356,7 @@ export function MessageTimeline(props: {
onTouchEnd={handleListTouchEnd}
onTouchCancel={handleListTouchEnd}
onPointerDown={handleListPointerDown}
onKeyDown={handleListKeyDown}
onScroll={handleListScroll}
onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full"
@@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test"
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { reuseTimelineRows } from "./row-reconciliation"
import { TimelineRow } from "./timeline-row"
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
new TimelineRow.AssistantPart({
userMessageID,
group: {
key,
type: "context",
refs: partIDs.map((partID) => ({ messageID: "assistant-1", partID })),
} satisfies PartGroup,
previousAssistantPart: false,
})
const user = (userMessageID = "user-1") => new TimelineRow.UserMessage({ userMessageID, anchor: true })
const keys = (rows: TimelineRow.TimelineRow[]) => rows.map(TimelineRow.key)
describe("reuseTimelineRows", () => {
test.each([
{
name: "reuses an unchanged context group",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:a", ["a", "b"])],
expected: ["assistant-part:user-1:context:a"],
reused: [[0, 0]],
},
{
name: "preserves the group key when a member is appended",
previous: [context("context:a", ["a"])],
rows: [context("context:a", ["a", "b"])],
expected: ["assistant-part:user-1:context:a"],
reused: [],
},
{
name: "preserves the group key when the first member is removed",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:b", ["b"])],
expected: ["assistant-part:user-1:context:a"],
reused: [],
},
{
name: "lets only the natural owner retain an old key after a split",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:a", ["a"]), context("context:b", ["b"])],
expected: ["assistant-part:user-1:context:a", "assistant-part:user-1:context:b"],
reused: [],
},
{
name: "chooses the earliest prior key when groups merge",
previous: [context("context:a", ["a"]), context("context:b", ["b"])],
rows: [context("context:b", ["b", "a"])],
expected: ["assistant-part:user-1:context:a"],
reused: [],
},
{
name: "reserves an old key for its natural owner when two new groups compete",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:b", ["b"]), context("context:a", ["a"])],
expected: ["assistant-part:user-1:context:b", "assistant-part:user-1:context:a"],
reused: [],
},
{
name: "does not reuse context identity across user messages",
previous: [context("context:a", ["a", "b"], "user-1")],
rows: [context("context:b", ["b"], "user-2")],
expected: ["assistant-part:user-2:context:b"],
reused: [],
},
{
name: "reuses an unaffected ordinary row",
previous: [user()],
rows: [user()],
expected: ["user-message:user-1"],
reused: [[0, 0]],
},
{
name: "does not create accidental key collisions",
previous: [context("context:a", ["a", "b", "c"])],
rows: [context("context:b", ["b"]), context("context:a", ["a"]), context("context:c", ["c"])],
expected: [
"assistant-part:user-1:context:b",
"assistant-part:user-1:context:a",
"assistant-part:user-1:context:c",
],
reused: [],
},
])("$name", ({ previous, rows, expected, reused }) => {
const result = reuseTimelineRows([...previous], [...rows])
expect(keys(result)).toEqual([...expected])
expect(new Set(keys(result)).size).toBe(result.length)
reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex]))
})
})
@@ -1,8 +1,11 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { createMemo, mapArray, type Accessor } from "solid-js"
import { reuseTimelineRows } from "./row-reconciliation"
import { Timeline, TimelineRow } from "./rows"
export { reuseTimelineRows } from "./row-reconciliation"
const emptyAssistantMessages: AssistantMessage[] = []
export function createTimelineProjection(input: {
@@ -102,15 +105,3 @@ export function createTimelineProjection(input: {
rows,
}
}
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
if (!previous?.length) return rows
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
const next = rows.map((row) => {
const existing = byKey.get(TimelineRow.key(row))
if (!existing) return row
return TimelineRow.equals(existing, row) ? existing : row
})
if (previous.length === next.length && previous.every((row, index) => row === next[index])) return previous
return next
}
@@ -0,0 +1,56 @@
import { TimelineRow } from "./timeline-row"
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
type PriorContext = { index: number; row: ContextRow }
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
if (!previous?.length) return rows
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
const contextByPart = new Map<string, PriorContext>()
previous.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
row.group.refs.forEach((ref) => contextByPart.set(`${row.userMessageID}:${ref.partID}`, { index, row }))
})
const reserved = new Map<string, number>()
rows.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
const key = TimelineRow.key(row)
if (byKey.has(key) && !reserved.has(key)) reserved.set(key, index)
})
const claimed = new Set<string>()
const next = rows.map((input, index) => {
const row = stabilizeContextKey(contextByPart, reserved, input, index, claimed)
const existing = byKey.get(TimelineRow.key(row))
if (!existing) return row
return TimelineRow.equals(existing, row) ? existing : row
})
if (previous.length === next.length && previous.every((row, index) => row === next[index])) return previous
return next
}
function stabilizeContextKey(
contextByPart: Map<string, PriorContext>,
reserved: Map<string, number>,
row: TimelineRow.TimelineRow,
rowIndex: number,
claimed: Set<string>,
) {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return row
const existing = row.group.refs.reduce<PriorContext | undefined>((result, ref) => {
const candidate = contextByPart.get(`${row.userMessageID}:${ref.partID}`)
if (!candidate) return result
const key = TimelineRow.key(candidate.row)
if (claimed.has(key)) return result
const owner = reserved.get(key)
if (owner !== undefined && owner !== rowIndex) return result
return !result || candidate.index < result.index ? candidate : result
}, undefined)
if (!existing) return row
const key = TimelineRow.key(existing.row)
claimed.add(key)
if (row.group.key === existing.row.group.key) return row
return new TimelineRow.AssistantPart({
...row,
group: { ...row.group, key: existing.row.group.key },
})
}
@@ -1,9 +1,9 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
import { groupParts, PartGroup, renderable } from "@opencode-ai/session-ui/message-part"
import { Data, Equal } from "effect"
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row"
export type SummaryDiff = SnapshotFileDiff & { file: string }
export { TimelineRow, type SummaryDiff } from "./timeline-row"
export type TimelineRowMap = {
TurnGap: { userMessageID: string }
@@ -29,81 +29,6 @@ export type TimelineRowMap = {
Error: { userMessageID: string; text: string }
}
export namespace TimelineRow {
export class TurnGap extends Data.TaggedClass("TurnGap")<{
userMessageID: string
}> {}
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
userMessageID: string
}> {}
export class UserMessage extends Data.TaggedClass("UserMessage")<{
userMessageID: string
anchor: boolean
}> {}
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
userMessageID: string
label: "compaction" | "interrupted"
}> {}
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
}> {}
export class Thinking extends Data.TaggedClass("Thinking")<{
userMessageID: string
reasoningHeading?: string
}> {}
export class DiffSummary extends Data.TaggedClass("DiffSummary")<{
userMessageID: string
diffs: SummaryDiff[]
}> {}
export class Error extends Data.TaggedClass("Error")<{
userMessageID: string
text: string
}> {}
export class Retry extends Data.TaggedClass("Retry")<{
userMessageID: string
}> {}
export type TimelineRow =
| TurnGap
| CommentStrip
| UserMessage
| TurnDivider
| AssistantPart
| Thinking
| DiffSummary
| Error
| Retry
export const key = (row: TimelineRow) => {
switch (row._tag) {
case "TurnGap":
return `turn-gap:${row.userMessageID}`
case "CommentStrip":
return `comment-strip:${row.userMessageID}`
case "UserMessage":
return `user-message:${row.userMessageID}`
case "TurnDivider":
return `turn-divider:${row.userMessageID}:${row.label}`
case "AssistantPart":
return `assistant-part:${row.userMessageID}:${row.group.key}`
case "Thinking":
return `thinking:${row.userMessageID}`
case "DiffSummary":
return `diff-summary:${row.userMessageID}`
case "Error":
return `error:${row.userMessageID}`
case "Retry":
return `retry:${row.userMessageID}`
}
}
export function equals(a: TimelineRow, b: TimelineRow) {
return Equal.equals(a, b)
}
}
export namespace Timeline {
export function constructMessageRows(
userMessage: UserMessage,
@@ -0,0 +1,80 @@
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { Data, Equal } from "effect"
export type SummaryDiff = SnapshotFileDiff & { file: string }
export namespace TimelineRow {
export class TurnGap extends Data.TaggedClass("TurnGap")<{
userMessageID: string
}> {}
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
userMessageID: string
}> {}
export class UserMessage extends Data.TaggedClass("UserMessage")<{
userMessageID: string
anchor: boolean
}> {}
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
userMessageID: string
label: "compaction" | "interrupted"
}> {}
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
}> {}
export class Thinking extends Data.TaggedClass("Thinking")<{
userMessageID: string
reasoningHeading?: string
}> {}
export class DiffSummary extends Data.TaggedClass("DiffSummary")<{
userMessageID: string
diffs: SummaryDiff[]
}> {}
export class Error extends Data.TaggedClass("Error")<{
userMessageID: string
text: string
}> {}
export class Retry extends Data.TaggedClass("Retry")<{
userMessageID: string
}> {}
export type TimelineRow =
| TurnGap
| CommentStrip
| UserMessage
| TurnDivider
| AssistantPart
| Thinking
| DiffSummary
| Error
| Retry
export const key = (row: TimelineRow) => {
switch (row._tag) {
case "TurnGap":
return `turn-gap:${row.userMessageID}`
case "CommentStrip":
return `comment-strip:${row.userMessageID}`
case "UserMessage":
return `user-message:${row.userMessageID}`
case "TurnDivider":
return `turn-divider:${row.userMessageID}:${row.label}`
case "AssistantPart":
return `assistant-part:${row.userMessageID}:${row.group.key}`
case "Thinking":
return `thinking:${row.userMessageID}`
case "DiffSummary":
return `diff-summary:${row.userMessageID}`
case "Error":
return `error:${row.userMessageID}`
case "Retry":
return `retry:${row.userMessageID}`
}
}
export function equals(a: TimelineRow, b: TimelineRow) {
return Equal.equals(a, b)
}
}
@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test"
import { filterReviewFiles, reviewDiffKinds } from "./review-diff-kinds"
describe("reviewDiffKinds", () => {
test("maps file and directory kinds", () => {
const kinds = reviewDiffKinds([
{ file: "src/a.ts", additions: 1, deletions: 0, status: "added" },
{ file: "src/b.ts", additions: 0, deletions: 2, status: "deleted" },
])
expect(kinds.get("src/a.ts")).toBe("add")
expect(kinds.get("src/b.ts")).toBe("del")
expect(kinds.get("src")).toBe("mix")
})
})
describe("filterReviewFiles", () => {
test("filters by path substring", () => {
const files = ["src/a.ts", "src/b.ts", "lib/c.ts"]
expect(filterReviewFiles(files, "b.ts")).toEqual(["src/b.ts"])
expect(filterReviewFiles(files, "")).toEqual(files)
})
})
@@ -0,0 +1,42 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { Kind } from "@/components/file-tree-v2"
export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
export function normalizePath(p: string) {
return p.replaceAll("\\", "/").replace(/\/+$/, "")
}
export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
return typeof value.file === "string"
}
export function reviewDiffKinds(diffs: RenderDiff[]) {
const merge = (a: Kind | undefined, b: Kind) => {
if (!a) return b
if (a === b) return a
return "mix" as const
}
const out = new Map<string, Kind>()
for (const diff of diffs) {
const file = normalizePath(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind)
const parts = file.split("/")
parts.slice(0, -1).forEach((_, idx) => {
const dir = parts.slice(0, idx + 1).join("/")
if (!dir) return
out.set(dir, merge(out.get(dir), kind))
})
}
return out
}
export function filterReviewFiles(files: string[], query: string) {
const value = query.trim().toLowerCase()
if (!value) return files
return files.filter((file) => file.toLowerCase().includes(value))
}
@@ -0,0 +1,40 @@
import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
type SessionReviewExpandMode,
} from "@opencode-ai/session-ui/v2/session-review-v2"
import { createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
export function createReviewPanelV2State() {
const [store, setStore] = persisted(
Persist.global("review-panel-v2"),
createStore({
sidebarOpened: true,
sidebarWidth: SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT,
expandMode: "collapse" as SessionReviewExpandMode,
}),
)
// The filter is transient by design: a persisted filter would silently hide
// files after a reload.
const [filter, setFilter] = createSignal("")
return {
sidebarOpened: () => store.sidebarOpened,
sidebarWidth: () => store.sidebarWidth,
filter,
setFilter,
expandMode: () => store.expandMode,
setExpandMode: (mode: SessionReviewExpandMode) => setStore("expandMode", mode),
resizeSidebar: (width: number) =>
setStore(
"sidebarWidth",
Math.min(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, Math.max(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, width)),
),
toggleSidebar: () => setStore("sidebarOpened", (opened) => !opened),
}
}
export type ReviewPanelV2State = ReturnType<typeof createReviewPanelV2State>
@@ -0,0 +1,234 @@
import { createMemo, createSignal, Show, type JSX } from "solid-js"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
SessionReviewV2,
SessionReviewV2Sidebar,
SessionReviewV2SidebarToggle,
} from "@opencode-ai/session-ui/v2/session-review-v2"
import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2"
import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2"
import type {
SessionReviewComment,
SessionReviewCommentActions,
SessionReviewCommentDelete,
SessionReviewCommentUpdate,
SessionReviewDiffStyle,
SessionReviewFocus,
SessionReviewLineComment,
} from "@opencode-ai/session-ui/session-review"
import FileTreeV2 from "@/components/file-tree-v2"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import {
filterRenderableDiff,
filterReviewFiles,
reviewDiffKinds,
type RenderDiff,
} from "@/pages/session/v2/review-diff-kinds"
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
export type ReviewPanelV2Props = {
title?: JSX.Element
empty?: JSX.Element
diffs: () => ReviewDiff[]
diffsReady: () => boolean
activeFile?: string
onSelectFile: (path: string) => void
diffStyle: SessionReviewDiffStyle
onDiffStyleChange?: (style: SessionReviewDiffStyle) => void
state: ReviewPanelV2State
onLineComment?: (comment: SessionReviewLineComment) => void
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
lineCommentActions?: SessionReviewCommentActions
comments?: SessionReviewComment[]
focusedComment?: SessionReviewFocus | null
onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void
}
export function ReviewPanelV2(props: ReviewPanelV2Props) {
const sdk = useSDK()
const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff))
const filteredFiles = createMemo(() =>
filterReviewFiles(
diffs().map((diff) => diff.file),
props.state.filter(),
),
)
const searching = createMemo(() => props.state.filter().trim().length > 0)
const kinds = createMemo(() => reviewDiffKinds(diffs()))
const activeDiff = createMemo(() => {
// A focused comment takes over the preview until the preview applies it and
// clears the focus; the owner then persists the file as the active selection.
const focus = props.focusedComment
if (focus && diffs().some((diff) => diff.file === focus.file)) return focus.file
const active = props.activeFile
if (searching()) return active
const files = filteredFiles()
if (active && files.includes(active)) return active
return files[0]
})
const activeItem = createMemo(() => diffs().find((diff) => diff.file === activeDiff()))
const readFile = async (path: string) =>
sdk()
.client.file.read({ path })
.then((x) => x.data)
.catch((error) => {
console.debug("[session-review-v2] failed to read file", { path, error })
return undefined
})
return (
<SessionReviewV2
title={props.title}
stats={<DiffChanges changes={diffs()} />}
empty={props.empty}
sidebarOpen={props.state.sidebarOpened()}
sidebarToggle={
<SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
}
sidebar={
// Always mounted: the sidebar header hosts the changes-mode dropdown,
// which must stay reachable when the current mode has zero diffs.
<ReviewPanelV2Sidebar
title={props.title}
state={props.state}
diffsReady={props.diffsReady}
onSelectFile={props.onSelectFile}
diffs={diffs}
filteredFiles={filteredFiles}
searching={searching}
kinds={kinds}
activeDiff={activeDiff}
/>
}
activeFile={activeDiff()}
files={filteredFiles()}
onSelectFile={props.onSelectFile}
diffStyle={props.diffStyle}
onDiffStyleChange={props.onDiffStyleChange}
expandMode={props.state.expandMode()}
onExpandModeChange={props.state.setExpandMode}
hasDiffs={diffs().length > 0}
preview={
// Key on the file path, not the diff object identity, so refreshed diff data
// updates the mounted preview instead of remounting the whole viewer.
<Show when={activeDiff()} keyed>
{(file) => (
<Show when={activeItem()}>
{(diff) => (
<SessionReviewFilePreviewV2
file={file}
diff={diff()}
diffStyle={props.diffStyle}
expandMode={props.state.expandMode()}
readFile={readFile}
onLineComment={props.onLineComment}
onLineCommentUpdate={props.onLineCommentUpdate}
onLineCommentDelete={props.onLineCommentDelete}
lineCommentActions={props.lineCommentActions}
comments={props.comments}
focusedComment={props.focusedComment}
onFocusedCommentChange={props.onFocusedCommentChange}
/>
)}
</Show>
)}
</Show>
}
/>
)
}
function ReviewPanelV2Sidebar(props: {
title?: JSX.Element
state: ReviewPanelV2State
diffsReady: () => boolean
onSelectFile: (path: string) => void
diffs: () => RenderDiff[]
filteredFiles: () => string[]
searching: () => boolean
kinds: () => ReturnType<typeof reviewDiffKinds>
activeDiff: () => string | undefined
}) {
const language = useLanguage()
const [explicitHighlight, setExplicitHighlight] = createSignal<string | undefined>()
const highlightedPath = createMemo(() => {
if (!props.searching()) return undefined
const files = props.filteredFiles()
if (files.length === 0) return undefined
const explicit = explicitHighlight()
if (explicit && files.includes(explicit)) return explicit
return files[0]
})
const onFilterKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => {
if (!props.searching()) return
applyFileListKeyDown(event, props.filteredFiles(), highlightedPath(), {
onHighlight: setExplicitHighlight,
onSelect: props.onSelectFile,
})
}
return (
<SessionReviewV2Sidebar
open={props.state.sidebarOpened()}
title={props.title}
stats={<DiffChanges changes={props.diffs()} />}
filter={props.state.filter()}
onFilterChange={props.state.setFilter}
onFilterKeyDown={onFilterKeyDown}
width={props.state.sidebarWidth()}
onWidthChange={props.state.resizeSidebar}
minWidth={SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN}
maxWidth={SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX}
>
<Show
when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<Show
when={props.searching()}
fallback={
<FileTreeV2
path=""
allowed={props.filteredFiles()}
kinds={props.kinds()}
draggable={false}
active={props.activeDiff()}
onFileClick={(node) => props.onSelectFile(node.path)}
/>
}
>
<Show
when={props.filteredFiles().length > 0}
fallback={<div class="px-2 py-2 text-12-regular text-text-weak">{language.t("palette.empty")}</div>}
>
<SessionFileListV2
files={props.filteredFiles()}
kinds={props.kinds()}
active={props.activeDiff()}
highlighted={highlightedPath()}
onFileClick={(path) => {
setExplicitHighlight(path)
props.onSelectFile(path)
}}
/>
</Show>
</Show>
</Show>
</SessionReviewV2Sidebar>
)
}
@@ -0,0 +1,109 @@
import { FileIcon } from "@opencode-ai/ui/file-icon"
import "@opencode-ai/ui/v2/file-tree-v2.css"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createEffect, For, Show } from "solid-js"
import { kindChange, kindLabel, type Kind } from "@/components/file-tree-v2"
import { normalizePath } from "@/pages/session/v2/review-diff-kinds"
// Drives the highlight/selection of the flat search-result list from the filter
// input's keyboard events.
export function applyFileListKeyDown(
event: KeyboardEvent,
files: readonly string[],
highlighted: string | undefined,
options: { onHighlight: (path: string) => void; onSelect: (path: string) => void },
) {
if (files.length === 0) return
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
const currentIndex = highlighted ? files.indexOf(highlighted) : -1
const delta = event.key === "ArrowDown" ? 1 : -1
const start = currentIndex === -1 ? (delta > 0 ? 0 : files.length - 1) : currentIndex + delta
const index = Math.max(0, Math.min(files.length - 1, start))
options.onHighlight(files[index]!)
event.preventDefault()
return
}
if (event.key !== "Enter") return
const target = highlighted ?? files[0]
if (!target) return
options.onSelect(target)
event.preventDefault()
}
// Flat variant of FileTreeV2 for filtered results: reuses its data-component and
// row data-slots on purpose so file-tree-v2.css styles both. data-highlighted has
// no CSS of its own — it folds into data-selected below and only exists as the
// scrollIntoView query hook.
export function SessionFileListV2(props: {
files: readonly string[]
active?: string
highlighted?: string
kinds?: ReadonlyMap<string, Kind>
onFileClick: (path: string) => void
}) {
const active = () => normalizePath(props.active ?? "")
const highlighted = () => normalizePath(props.highlighted ?? "")
let rootRef: HTMLDivElement | undefined
createEffect(() => {
highlighted()
if (!rootRef) return
queueMicrotask(() => {
const row = rootRef?.querySelector<HTMLElement>('[data-slot="file-tree-v2-row"][data-highlighted]')
row?.scrollIntoView({ block: "nearest" })
})
})
return (
<div
ref={(el) => {
rootRef = el
}}
data-component="file-tree-v2"
>
<For each={props.files}>
{(path) => {
const normalized = normalizePath(path)
const selected = () => {
if (highlighted()) return highlighted() === normalized
return active() === normalized
}
const highlightedRow = () => highlighted() === normalized
const kind = () => props.kinds?.get(normalized)
const directory = () => (normalized.includes("/") ? getDirectory(normalized) : undefined)
const filename = () => getFilename(normalized)
return (
<button
type="button"
data-slot="file-tree-v2-row"
data-selected={selected() ? "" : undefined}
data-highlighted={highlightedRow() ? "" : undefined}
style="padding-left: 8px"
onClick={() => props.onFileClick(path)}
>
<span class="filetree-iconpair size-4">
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
<span class="flex min-w-0 flex-1 items-center overflow-hidden whitespace-nowrap">
<Show when={directory()}>
{(value) => <span class="text-12-medium text-text-muted truncate min-w-0 shrink">{value()}</span>}
</Show>
<span class="text-12-medium text-text-base truncate min-w-0 shrink-0">{filename()}</span>
</span>
<Show when={kind()}>
{(value) => (
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
{kindLabel(value())}
</span>
)}
</Show>
</button>
)
}}
</For>
</div>
)
}
+14
View File
@@ -42,6 +42,20 @@ function unwrapNamedError(error: unknown): unknown {
return error
}
// Client-synthesized session not-found errors share one constructor and
// predicate so the message contract cannot drift between the sync store
// (server-session.ts), the route lineage (session-lineage.ts), and the
// not-found fallback matching (session.tsx).
const sessionNotFoundMessage = (sessionID: string) => `Session not found: ${sessionID}`
export function sessionNotFoundError(sessionID: string) {
return new Error(sessionNotFoundMessage(sessionID))
}
export function isLocalSessionNotFoundError(error: unknown, sessionID: string) {
return error instanceof Error && error.message === sessionNotFoundMessage(sessionID)
}
export function isSessionNotFoundError(error: unknown, sessionID: string) {
const unwrapped = unwrapNamedError(error)
if (typeof unwrapped !== "object" || unwrapped === null) return false
+1 -14
View File
@@ -1,13 +1,6 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import {
legacySessionHref,
legacySessionServer,
requireServerKey,
rootSession,
selectSessionLineage,
sessionHref,
} from "./session-route"
import { legacySessionHref, legacySessionServer, requireServerKey, rootSession, sessionHref } from "./session-route"
describe("session routes", () => {
test("uses the unique persisted server for a legacy session route", () => {
@@ -75,10 +68,4 @@ describe("session routes", () => {
expect(rootSession(sessions.child, async (id) => sessions[id]!)).rejects.toThrow("Session parent cycle: child")
})
test("ignores a resolved lineage retained from the previous route", () => {
const previous = { session: { id: "A" }, root: { id: "A" } }
expect(selectSessionLineage("B", undefined, previous)).toBeUndefined()
})
})

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