Compare commits

...

122 Commits

Author SHA1 Message Date
LukeParkerDev d8525d71fb fix(app): preserve framed row paint 2026-07-09 14:41:28 +10:00
LukeParkerDev ae27fedfb0 fix(app): preserve tool card outlines 2026-07-09 13:37:11 +10:00
LukeParkerDev d0bce4f692 fix(ui): preserve shell outline position 2026-07-09 11:08:20 +10:00
Luke Parker 02724220ed Merge remote-tracking branch 'origin/dev' into shell-subpixel-resolve
# Conflicts:
#	packages/session-ui/src/components/message-part.css
2026-07-08 23:56:12 +00:00
opencode-agent[bot] 0abbcddac2 chore: add missing team members (#35980)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-09 09:53:05 +10:00
Luke Parker dd25d143c5 fix(app): reserve review pane minimum instead of capping chat width at 45% (#35078) 2026-07-09 09:47:46 +10:00
Luke Parker d8a57ee15c fix(app): unmount hidden session panes (#35628) 2026-07-09 09:44:02 +10:00
Luke Parker aa52d30d7f feat(app): persist review state per session (#35488) 2026-07-08 23:43:28 +00:00
Luke Parker 83d633fd13 fix(app): route legacy drafts to session page (#35808) 2026-07-09 07:38:56 +08:00
Luke Parker 824455e2f1 fix(app): load capped review patches (#35633) 2026-07-09 09:09:32 +10:00
Luke Parker 62de2b3695 feat(app): add inline file browser tabs (#35829) 2026-07-09 09:09:21 +10:00
opencode-agent[bot] 7550aa4b89 chore: update nix node_modules hashes 2026-07-08 22:51:45 +00:00
opencode-agent[bot] c33a2011c4 fix(app): prevent command palette first-open flash (#35858) 2026-07-09 06:51:04 +08:00
David Hill fff6764ed4 fix(console): update square avatar previews (#35975) 2026-07-08 22:48:11 +00:00
opencode-agent[bot] 9644a008a4 chore: generate 2026-07-08 22:31:09 +00:00
Aiden Cline ccb6b7c3ea fix: improve xai cache hit rate (#35970) 2026-07-08 17:29:36 -05:00
opencode-agent[bot] f95f877e5f fix(core): watch only git projects (#35957)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-08 20:58:41 +00:00
Frank fe96d4aebd zen: grok 4.5
deploy / deploy (push) Has been cancelled
2026-07-08 15:52:56 -04:00
Frank 3a95d56144 Merge branch 'dev' of https://github.com/anomalyco/opencode into dev 2026-07-08 15:30:52 -04:00
Frank ecd8be7593 zen: grok 4.5 2026-07-08 15:30:49 -04:00
opencode-agent[bot] a72c3880bb chore: generate 2026-07-08 16:30:17 +00:00
Adam ccd8676bee feat(stats): add model comparison home 2026-07-08 11:28:28 -05:00
Adam 5a533e366f feat(stats): add model comparison pages 2026-07-08 11:28:27 -05:00
Luke Parker 77429f5982 feat(app): sync product translations (#35856)
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
containers / build (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-07-08 14:57:28 +10:00
Brendan Allan 6cfe4bc34f fix(app): route new session shortcut through tabs (#35849) 2026-07-08 11:44:56 +08:00
Brendan Allan 95013d2936 fix(app): keep session routes within layouts (#35842) 2026-07-08 03:24:21 +00:00
Adam 14a5529793 feat(data): redesign model peers
deploy / deploy (push) Has been cancelled
2026-07-07 11:15:43 -05:00
opencode 0b2e8707e3 sync release versions for v1.17.15 2026-07-07 15:31:36 +00:00
冯基魁 adf178a6b9 fix(llm): classify zai token limit overflow (#35671) 2026-07-07 09:46:27 -05:00
opencode-agent[bot] 78f85b1cd6 fix(core): ensure relevant files survive compaction (#35636)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-07 09:39:01 -05:00
opencode-agent[bot] 888dc67cc3 chore: generate 2026-07-07 14:25:21 +00:00
Dustin Deus fd9ee435ec fix(core): expand home-relative permission paths (#35737) 2026-07-07 16:23:50 +02:00
opencode-agent[bot] 1c25b2f298 chore: generate 2026-07-07 11:55:25 +00:00
Adam ea9fffce2e feat(data): redesign model efficiency 2026-07-07 06:53:36 -05:00
Brendan Allan 516f0266b8 fix(app): hide markdown task markers (#35670) 2026-07-07 15:22:49 +08:00
opencode-agent[bot] 39397deb8d chore: generate
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
containers / build (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-07-07 06:44:40 +00:00
Brendan Allan 65fd2e5c91 fix(app): prioritize shortcuts in terminal (#35668) 2026-07-07 06:43:30 +00:00
opencode-agent[bot] c5fe32fbb1 fix(app): show unread for pending questions (#34684)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-07 13:27:19 +08:00
opencode-agent[bot] c48b77d6f9 chore: generate 2026-07-07 05:12:15 +00:00
Brendan Allan 64fd7a2027 fix(app): persist session tab info (#34934) 2026-07-07 05:11:08 +00:00
opencode-agent[bot] ae259d87f0 fix(app): apply settings themes on select (#35011)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-07 13:03:25 +08:00
Aarav Sareen 50374abab8 feat(app): change traffic light position, fix sequoia bug (#35081) 2026-07-07 12:49:28 +08:00
opencode-agent[bot] 0497e8badf chore: generate 2026-07-07 04:46:52 +00:00
Aarav Sareen 2a08f8f5f4 feat(app): reintroduce model context tooltip in model selector (#35087) 2026-07-07 12:45:42 +08:00
opencode-agent[bot] 9353559088 chore: generate 2026-07-07 02:37:53 +00:00
Aarav Sareen 6aff2ee169 feat(app): visual improvements (#35134) 2026-07-07 10:36:27 +08:00
James Long 254a481e5d fix(config): handle unavailable config directories (#35632)
Co-authored-by: James Long <jlongster@users.noreply.github.com>
2026-07-06 22:11:13 -04:00
Aarav Sareen 26885e7118 feat(app): align cmd k menu with v2 styles (#35152)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-07 08:52:57 +08:00
opencode-agent[bot] dba08013d1 chore: generate 2026-07-06 20:10:55 +00:00
Adam fc09646973 fix(data): refine model trend charts 2026-07-06 15:03:07 -05:00
Adam c3b9c9d888 feat(data): improved model usage section 2026-07-06 15:03:07 -05:00
opencode 203885d241 sync release versions for v1.17.14 2026-07-06 18:50:51 +00:00
opencode-agent[bot] 728e86b287 chore: generate 2026-07-06 18:20:49 +00:00
Aiden Cline d5aa79c73a feat(codemode): sync v2 implementation (#35574) 2026-07-06 13:19:21 -05:00
opencode-agent[bot] eb6ff0c1e0 chore: update nix node_modules hashes 2026-07-06 13:35:10 +00:00
opencode-agent[bot] 0df44c4e91 chore: generate 2026-07-06 11:02:19 +00:00
Simon Klee 561070fbc2 deps: upgrade OpenTUI to v0.4.3 (#35226) 2026-07-06 13:01:08 +02:00
Brendan Allan b0e41ff2c4 fix(app): use selected home project for new sessions (#35530) 2026-07-06 16:37:50 +08:00
opencode-agent[bot] 977a40af68 chore: generate 2026-07-06 08:21:25 +00:00
Brendan Allan dffecb6478 fix(desktop): gate first launch onboarding (#34930) 2026-07-06 16:20:15 +08:00
Brendan Allan 7f57d2a9ac feat(app): show draft server status in titlebar (#35521) 2026-07-06 16:12:53 +08:00
opencode-agent[bot] 377d5d2287 fix(app): avoid shortcut settings flash (#35349)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-06 16:12:30 +08:00
Brendan Allan 38bb38ecb2 refactor(app): unify provider connect dialog (#35518) 2026-07-06 07:50:08 +00:00
Luke Parker 3a149ba71c fix(app): optimize large review panes (#35375)
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-06 16:26:03 +10:00
opencode-agent[bot] 14df88eab5 fix(app): preserve provider dialog backdrop (#35370)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-06 06:08:59 +00:00
opencode-agent[bot] e12cb7fb6b chore: generate 2026-07-06 05:53:41 +00:00
Aiden Cline d4f7039932 fix(codemode): unify catalog signatures (#35452) 2026-07-06 00:52:37 -05:00
opencode-agent[bot] e0ec9be238 chore: update nix node_modules hashes 2026-07-06 03:49:25 +00:00
Aiden Cline 2b34df94fa fix(mcp): preserve metadata across tool pages (#35439) 2026-07-05 22:28:28 -05:00
opencode-agent[bot] 68f225a11d fix(provider): preserve OpenRouter small model effort (#35478)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-05 18:50:25 -05:00
David Hill e9f5d34096 fix(tui): shorten home tips (#31966) 2026-07-05 14:44:10 -05:00
Aiden Cline d3459eb740 test(mcp): replace module mocks with real servers (#35450) 2026-07-05 12:49:31 -05:00
Aiden Cline be73f465df refactor(codemode): namespace public types (#35425) 2026-07-05 10:34:58 -05:00
Aiden Cline f14eafe9db refactor(codemode): remove generic agent tool (#35417) 2026-07-05 09:30:46 -05:00
Jack b7e4f1ef74 fix: update Feishu community link (#35392)
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
deploy / deploy (push) Has been cancelled
2026-07-05 14:00:14 +08:00
Luke Parker efd5f0a80f fix(app): preserve timeline selection autoscroll (#35383) 2026-07-05 02:47:37 +00:00
opencode-agent[bot] 7135bc4fb5 chore: generate 2026-07-05 02:02:39 +00:00
Aarav Sareen 7cf8d1ca6d feat(app): terminal improvements (#34747)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-05 02:01:30 +00:00
opencode-agent[bot] 78bca5c707 chore: generate 2026-07-05 01:39:09 +00:00
usrnk1 82db86e2a1 feat(desktop): reopen closed tabs and background tab open (#35010)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-07-05 01:37:59 +00:00
opencode-agent[bot] 476e3fc382 chore: generate 2026-07-05 00:51:28 +00:00
Luke Parker a12d50e15a fix(app): hydrate timeline message parents (#35269) 2026-07-05 10:49:46 +10:00
opencode-agent[bot] 1b9b260458 chore: generate 2026-07-04 21:18:39 +00:00
Aiden Cline a8983bd2c7 feat(codemode): add OpenAPI tool adapter (#35192) 2026-07-04 16:17:30 -05:00
opencode-agent[bot] 709af58612 fix(core): stop after declined permissions (#35356)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-04 16:17:11 -05:00
opencode-agent[bot] bcbbf32569 fix(app): use v2 tooltip for prompt context (#35351)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
2026-07-04 14:54:46 -04:00
Simon Klee 7a8e7c88f4 tui: preserve spinner registration (#35292) 2026-07-04 09:44:23 +02:00
Luke Parker a22676720d fix(app): match new session panel corners (#35257)
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
containers / build (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-07-04 04:08:15 +00:00
Luke Parker f63a451b6f fix(desktop): stabilize esm shim injection (#35270) 2026-07-04 13:31:13 +10:00
opencode-agent[bot] 8b6a2b173b fix(app): delay initial tab preview (#35266)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-03 22:42:17 -04:00
Luke Parker 933dbfdb36 fix(app): restore unfocused composer typing (#35249) 2026-07-03 23:59:23 +00:00
opencode-agent[bot] b44bc0ad07 cleanup: tweak compaction prompt (#35220)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-03 15:26:03 -05:00
opencode-agent[bot] 436cd39d14 chore: generate 2026-07-03 18:42:23 +00:00
Aiden Cline ed6dc879be feat(opencode): gate execute tool behind code mode flag (#35185) 2026-07-03 13:41:15 -05:00
James Long 96d53c6716 refactor(core): move path resolve into fs service (#35202) 2026-07-03 13:25:25 -04:00
Aiden Cline 911fb7063a fix(tui): align execute child calls with task indentation (#35190) 2026-07-03 12:06:58 -05:00
Aiden Cline 27e8e2e22b refactor(opencode): fail the execute tool on program failure (#35180) 2026-07-03 11:27:36 -05:00
Aiden Cline 39c6dd1c32 feat(tui): render code-mode execute tool with child calls (#35113)
deploy / deploy (push) Has been cancelled
2026-07-03 11:17:21 -05:00
opencode-agent[bot] a09447bc9b chore: generate 2026-07-03 15:40:10 +00:00
Adam 16815473a4 chore: artifacts 2026-07-03 10:38:49 -05:00
Adam 1f47bbfce2 fix(stats): link market share labs 2026-07-03 10:34:13 -05:00
James Long 33ed95f09c fix(core): rewrite replacements while hoisting layers (#35175) 2026-07-03 11:26:49 -04:00
opencode-agent[bot] 2152624f37 chore: generate 2026-07-03 15:16:03 +00:00
Adam 7d84cb05ef feat(stats): add model momentum section 2026-07-03 10:13:56 -05:00
James Long eba0bd0397 chore: upgrade turbo (#35173) 2026-07-03 10:41:17 -04:00
Kit Langton 9aab24e95e fix(opencode): provide Observability beneath all route service graphs (#35171) 2026-07-03 10:00:30 -04:00
Aarav Sareen a4fed69a82 feat(app): dropdown search fix (#34961)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-03 10:31:37 +00:00
opencode-agent[bot] 41a3cfcdd9 chore: update nix node_modules hashes 2026-07-03 09:37:53 +00:00
opencode-agent[bot] 72845e09fc chore: generate 2026-07-03 09:25:41 +00:00
Aiden Cline abaab29cb3 feat(opencode): add code-mode MCP adapter (#35085) 2026-07-03 09:24:27 +00:00
opencode-agent[bot] 30936a9bca chore: generate 2026-07-03 08:11:01 +00:00
Aarav Sareen a9144eccf8 feat(app): improvements to model search (#34954) 2026-07-03 08:09:51 +00:00
Aiden Cline 458ec7b372 refactor(opencode): expose MCP tools in native shape from the service (#35103) 2026-07-03 03:05:36 -05:00
usrnk1 d46c02ba73 feat(desktop): papercut fixes (#34939) 2026-07-03 08:04:59 +00:00
opencode-agent[bot] caedf36844 chore: generate 2026-07-03 07:54:40 +00:00
Aarav Sareen 4c6e2a92ce feat(app): align subagent UI with v2 (#34931)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-03 15:53:31 +08:00
usrnk1 bf58fae51f feat(desktop): add recently closed projects to home (#34926) 2026-07-03 15:52:40 +08:00
Aiden Cline eb3476660f chore: remove root node-gyp pin after Node 24 setup (#35092)
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-03 00:45:17 -05:00
opencode-agent[bot] 7d48a22a63 chore: update nix node_modules hashes 2026-07-03 05:33:25 +00:00
opencode-agent[bot] 3f0fc22b6e chore: generate 2026-07-03 05:20:28 +00:00
Aiden Cline 2409c7a3d5 feat(codemode): add confined execution package (#35079) 2026-07-03 05:19:11 +00:00
Test ae0375f82f fix(ui): stabilize shell output outline 2026-06-25 09:17:33 +00:00
382 changed files with 57293 additions and 4458 deletions
+4
View File
@@ -1,4 +1,5 @@
adamdotdevin
arvsrn
Brendonovich
fwang
Hona
@@ -7,11 +8,14 @@ jayair
jlongster
kitlangton
kommander
ludvigrask
MrMushrooooom
nexxeln
R44VC0RP
rekram1-node
thdxr
simonklee
Slickstef11
usrnk1
vimtor
starptech
+1 -1
View File
@@ -125,4 +125,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
---
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode)
+1 -1
View File
@@ -125,4 +125,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
---
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode)
+3
View File
@@ -0,0 +1,3 @@
node_modules
.remotion
out/frame-*.png
+483
View File
@@ -0,0 +1,483 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "glm52-rise-video",
"dependencies": {
"@remotion/cli": "^4.0.384",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"remotion": "^4.0.384",
},
"devDependencies": {
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"typescript": "^5.8.2",
},
},
},
"packages": {
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/parser": ["@babel/parser@7.24.1", "", { "bin": "./bin/babel-parser.js" }, "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg=="],
"@babel/types": ["@babel/types@7.24.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" } }, "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w=="],
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@mediabunny/aac-encoder": ["@mediabunny/aac-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-JNzgdJoHMFFnv5imi1+dmjZMudsJ1zNCUCEkKjBh90cqGhAFg8xu4V2gsyxE2i6oq/YpWx23P+OvoANLSMcDzA=="],
"@mediabunny/flac-encoder": ["@mediabunny/flac-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-VpKmJO0xlYcFCRD6JvJlMbNQ6d/6YMHdO1gFIqWlZABHjSSL6BquNNEgWSCv5vdF8ELDBwIYBVZEslakIB/7GA=="],
"@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-JyzZyGeGRm2HVUQaGJ/VZT9OG+kG00mA8SLP/f3CO7+qWAmBncKc16WYXWHbZEo8Jn/ZGA6De32S5R2bTQbDqA=="],
"@module-federation/error-codes": ["@module-federation/error-codes@0.22.0", "", {}, "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug=="],
"@module-federation/runtime": ["@module-federation/runtime@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/runtime-core": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA=="],
"@module-federation/runtime-core": ["@module-federation/runtime-core@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA=="],
"@module-federation/runtime-tools": ["@module-federation/runtime-tools@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/webpack-bundler-runtime": "0.22.0" } }, "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA=="],
"@module-federation/sdk": ["@module-federation/sdk@0.22.0", "", {}, "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g=="],
"@module-federation/webpack-bundler-runtime": ["@module-federation/webpack-bundler-runtime@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
"@remotion/bundler": ["@remotion/bundler@4.0.483", "", { "dependencies": { "@remotion/media-parser": "4.0.483", "@remotion/studio": "4.0.483", "@remotion/studio-shared": "4.0.483", "@remotion/timeline-utils": "4.0.483", "@rspack/core": "1.7.11", "@rspack/plugin-react-refresh": "1.6.1", "css-loader": "7.1.4", "esbuild": "0.28.1", "react-refresh": "0.18.0", "remotion": "4.0.483", "style-loader": "4.0.0", "webpack": "5.105.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-5fQRMYgr2sxZPBOThnIVuFwHBq/FhusJ+Ke6xopga8io8ecK/mj9GaRXC3tgfMa+YWuu8ZSXZ0U9EsMZjjP5YQ=="],
"@remotion/canvas-capture": ["@remotion/canvas-capture@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JABgbfvXwjFp1E61tlDN9gVAH8OoeF4kUUELNwG9kcNOE4ex8+vCYEO6VKIaXZ4l8A1PEAQ5EK//NheTJ80gfw=="],
"@remotion/cli": ["@remotion/cli@4.0.483", "", { "dependencies": { "@remotion/bundler": "4.0.483", "@remotion/media-utils": "4.0.483", "@remotion/player": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio": "4.0.483", "@remotion/studio-server": "4.0.483", "@remotion/studio-shared": "4.0.483", "dotenv": "17.3.1", "minimist": "1.2.6", "prompts": "2.4.2", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" }, "bin": { "remotion": "remotion-cli.js", "remotionb": "remotionb-cli.js", "remotiond": "remotiond-cli.js" } }, "sha512-z4TQcgTfQbSIV4GQpCvIihDL77M+leQelbxv962PmfSKTT7z3XnNN732PcZ4sei2+Xg+d2SD95K4tI5XBYs5Gw=="],
"@remotion/compositor-darwin-arm64": ["@remotion/compositor-darwin-arm64@4.0.483", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CkEJXouGEoCs5PX3KOufkXrlT/Kgn0ZF7SEQ4meCYGYMZD+QPVtsmS1MXH8pQ4RHlFO6Tk7g0CIHMT3Y4yzpuA=="],
"@remotion/compositor-darwin-x64": ["@remotion/compositor-darwin-x64@4.0.483", "", { "os": "darwin", "cpu": "x64" }, "sha512-vDZK5U8FYbHGMjFC2lUgJRBz8KXwbwFSvEQi8FATD0roNg75SoaEJWrqAOIVv8v5eBjPvo18znDc36NLRYn1Eg=="],
"@remotion/compositor-linux-arm64-gnu": ["@remotion/compositor-linux-arm64-gnu@4.0.483", "", { "os": "linux", "cpu": "arm64" }, "sha512-1LzfYDY/DQxy3MIc9pCbTn1ObcBDcyI6MzODOwe1bTZl8TZxdUUt1M3hDrQ60AB6iHwruBAnzT9dnogiMIdSew=="],
"@remotion/compositor-linux-arm64-musl": ["@remotion/compositor-linux-arm64-musl@4.0.483", "", { "os": "linux", "cpu": "arm64" }, "sha512-nG1btxg0HXuLzulIqAg9NnxWyewZNrA0iLe82HdniaJjeWLORreGo4cEEJz1SjbDpQuPEY562XGkTO2DLKK5mA=="],
"@remotion/compositor-linux-x64-gnu": ["@remotion/compositor-linux-x64-gnu@4.0.483", "", { "os": "linux", "cpu": "x64" }, "sha512-CV4h6r2rNYh4P8utUzP6RM2vW2vq43nA3IvVOwRSuh9LwJh/7rwG53Oco4HanY/sstaNuymGpT57wdXwONU5CQ=="],
"@remotion/compositor-linux-x64-musl": ["@remotion/compositor-linux-x64-musl@4.0.483", "", { "os": "linux", "cpu": "x64" }, "sha512-moKvavbkoz5nteJZA4K6LMgTTlewhwIbtO7DYARZ3LK3ClGUKnYfFcmaPS15hMun02JPTOMIWbrlaszdR75mxA=="],
"@remotion/compositor-win32-x64-msvc": ["@remotion/compositor-win32-x64-msvc@4.0.483", "", { "os": "win32", "cpu": "x64" }, "sha512-eDpWe6Vy7ySHugwxlgaeohqdBwR1etxiymXzT13cgbPw2+p6d55z1tJ7VPTXyfu0o+UdyRTauWqD4msoxN0wow=="],
"@remotion/licensing": ["@remotion/licensing@4.0.483", "", {}, "sha512-GRtjykrxMmB5Cjpq1oVqeYIRXI95vid6afX0LTLfp1shD6MR4lTZRoRuopxrrZp1E4XFGXoY64b6dlIudgTyBQ=="],
"@remotion/media-parser": ["@remotion/media-parser@4.0.483", "", {}, "sha512-iva9Eof7QQX+3hGxH/N8pGi80wgdGuzOr825Zn7IuEe7IWdtRYWlFpCEghmyfhrwgaWa7RY51wg77ycR3c3nRg=="],
"@remotion/media-utils": ["@remotion/media-utils@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4T4WQh/U95kCBzgmz/EL/HL8Zeox/AKAL5ufsehrZFgwlcvH1EsKNJH/RWaAtNPRLF+pskEOgFzPYaczHeI8hg=="],
"@remotion/player": ["@remotion/player@4.0.483", "", { "dependencies": { "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-yyIQ9vinUqqS0fQeL52bPCWQIr8qDXCqQ4iys8P7dSZncdu8V29p4W2a0/4eKV8bv5+mjnJQ/U6nl9NxBF1hdA=="],
"@remotion/renderer": ["@remotion/renderer@4.0.483", "", { "dependencies": { "@remotion/licensing": "4.0.483", "@remotion/streaming": "4.0.483", "execa": "5.1.1", "remotion": "4.0.483", "source-map": "0.8.0-beta.0", "ws": "8.21.0" }, "optionalDependencies": { "@remotion/compositor-darwin-arm64": "4.0.483", "@remotion/compositor-darwin-x64": "4.0.483", "@remotion/compositor-linux-arm64-gnu": "4.0.483", "@remotion/compositor-linux-arm64-musl": "4.0.483", "@remotion/compositor-linux-x64-gnu": "4.0.483", "@remotion/compositor-linux-x64-musl": "4.0.483", "@remotion/compositor-win32-x64-msvc": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-eSKdQqm8rcl+GNyOhFsBgcPYWMcxTJWHL5xaNrd037piCaL3sdJ57OEO/AbfEvvOO14zwUvlsONKjlkLlEVyKg=="],
"@remotion/streaming": ["@remotion/streaming@4.0.483", "", {}, "sha512-96rqrk+l5AfriHOqmqxMn8rI6UJfFY7r5UMDZoSKqXWzTOwj+0VWHTRl9ilZQMXhYACaMSA/5yrL9jgQEK8Yuw=="],
"@remotion/studio": ["@remotion/studio@4.0.483", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@remotion/canvas-capture": "4.0.483", "@remotion/media-utils": "4.0.483", "@remotion/player": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio-shared": "4.0.483", "@remotion/timeline-utils": "4.0.483", "@remotion/web-renderer": "4.0.483", "@remotion/zod-types": "4.0.483", "mediabunny": "1.47.0", "memfs": "3.4.3", "open": "8.4.2", "remotion": "4.0.483", "semver": "7.5.3", "zod": "4.3.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-gNa1Lw8NmHl0jwFb5fk7rzA+WFTJQpGDT/49A38t9jpEwoF+h1mKTgY8gA202apzjBHrEECdFE450npnBOUpUw=="],
"@remotion/studio-server": ["@remotion/studio-server@4.0.483", "", { "dependencies": { "@babel/parser": "7.24.1", "@babel/types": "7.24.0", "@remotion/bundler": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio-shared": "4.0.483", "memfs": "3.4.3", "open": "8.4.2", "prettier": "3.8.1", "recast": "0.23.11", "remotion": "4.0.483", "semver": "7.5.3" } }, "sha512-2FSLbVN5N8bgtQPX+RQRbis557tJJnVTOZuEBZndfC9NOoPdARlSgEnwVer9tBNczUlc6B3KrPxyPSlS3UjL+Q=="],
"@remotion/studio-shared": ["@remotion/studio-shared@4.0.483", "", { "dependencies": { "remotion": "4.0.483" } }, "sha512-aXxBYUgsWgphHVq7T7bz+ICMGzfii7pcX1IxS5hbLMLJHqlDtk9g+qcxm4kJ2Fjj/Gb5gERKiNzYym8WZTIM2Q=="],
"@remotion/timeline-utils": ["@remotion/timeline-utils@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0" } }, "sha512-KTTMdpNA5uRLX+iisAITO+V9cJBk17F8EWvuUnAEVmAFOQTbGIyPI9HNw5pZmL7SlzOJdFjAtksTjipeYGkEug=="],
"@remotion/web-renderer": ["@remotion/web-renderer@4.0.483", "", { "dependencies": { "@mediabunny/aac-encoder": "1.47.0", "@mediabunny/flac-encoder": "1.47.0", "@mediabunny/mp3-encoder": "1.47.0", "@remotion/licensing": "4.0.483", "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-14iURfVWA/hcF/rJvkid1ZR5nmnVhkTIf071FabCFi6kdViOSVBugSglJ8tUPvWVbWmxlXGDGQD9CSU18IATww=="],
"@remotion/zod-types": ["@remotion/zod-types@4.0.483", "", { "dependencies": { "remotion": "4.0.483" }, "peerDependencies": { "zod": "4.3.6" } }, "sha512-KIVYIzFpZBDB0wxGGQu5tDBr4XOYRzvrPXjmo6O/FI5Xx7O8hgvJmBs9VDkEwonAOft86sg79hI19F1DTX1p4w=="],
"@rspack/binding": ["@rspack/binding@1.7.11", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "1.7.11", "@rspack/binding-darwin-x64": "1.7.11", "@rspack/binding-linux-arm64-gnu": "1.7.11", "@rspack/binding-linux-arm64-musl": "1.7.11", "@rspack/binding-linux-x64-gnu": "1.7.11", "@rspack/binding-linux-x64-musl": "1.7.11", "@rspack/binding-wasm32-wasi": "1.7.11", "@rspack/binding-win32-arm64-msvc": "1.7.11", "@rspack/binding-win32-ia32-msvc": "1.7.11", "@rspack/binding-win32-x64-msvc": "1.7.11" } }, "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q=="],
"@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@1.7.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig=="],
"@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@1.7.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA=="],
"@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA=="],
"@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw=="],
"@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ=="],
"@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg=="],
"@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@1.7.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "1.0.7" }, "cpu": "none" }, "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ=="],
"@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@1.7.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA=="],
"@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@1.7.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw=="],
"@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@1.7.11", "", { "os": "win32", "cpu": "x64" }, "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q=="],
"@rspack/core": ["@rspack/core@1.7.11", "", { "dependencies": { "@module-federation/runtime-tools": "0.22.0", "@rspack/binding": "1.7.11", "@rspack/lite-tapable": "1.1.0" }, "peerDependencies": { "@swc/helpers": ">=0.5.1" }, "optionalPeers": ["@swc/helpers"] }, "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew=="],
"@rspack/lite-tapable": ["@rspack/lite-tapable@1.1.0", "", {}, "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw=="],
"@rspack/plugin-react-refresh": ["@rspack/plugin-react-refresh@1.6.1", "", { "dependencies": { "error-stack-parser": "^2.1.4", "html-entities": "^2.6.0" }, "peerDependencies": { "react-refresh": ">=0.10.0 <1.0.0", "webpack-hot-middleware": "2.x" }, "optionalPeers": ["webpack-hot-middleware"] }, "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="],
"@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="],
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="],
"@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="],
"@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="],
"@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="],
"@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="],
"@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="],
"@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="],
"@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="],
"@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="],
"@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="],
"@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="],
"@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="],
"@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="],
"@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="],
"@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="],
"@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],
"@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
"acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
"ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.40", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw=="],
"browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="],
"chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="],
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-loader": ["css-loader@7.1.4", "", { "dependencies": { "icss-utils": "^5.1.0", "postcss": "^8.4.40", "postcss-modules-extract-imports": "^3.1.0", "postcss-modules-local-by-default": "^4.0.5", "postcss-modules-scope": "^3.2.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", "semver": "^7.6.3" }, "peerDependencies": { "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", "webpack": "^5.27.0" }, "optionalPeers": ["@rspack/core", "webpack"] }, "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.379", "", {}, "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA=="],
"enhanced-resolve": ["enhanced-resolve@5.24.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw=="],
"error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="],
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
"fs-monkey": ["fs-monkey@1.0.3", "", {}, "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q=="],
"get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="],
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"icss-utils": ["icss-utils@5.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA=="],
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"loader-runner": ["loader-runner@4.3.2", "", {}, "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w=="],
"lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="],
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"mediabunny": ["mediabunny@1.47.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-XQMZAcaKPkJ7hQ/Q2fvBdl3ZazQl2WVxDysUbJWh4PuAnLoerdsQBdPTDWdUdK6hh26LQ8Ue94MLLnmpWvlUYg=="],
"memfs": ["memfs@3.4.3", "", { "dependencies": { "fs-monkey": "1.0.3" } }, "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"minimist": ["minimist@1.2.6", "", {}, "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="],
"nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="],
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
"node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="],
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"postcss-modules-extract-imports": ["postcss-modules-extract-imports@3.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q=="],
"postcss-modules-local-by-default": ["postcss-modules-local-by-default@4.2.0", "", { "dependencies": { "icss-utils": "^5.0.0", "postcss-selector-parser": "^7.0.0", "postcss-value-parser": "^4.1.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw=="],
"postcss-modules-scope": ["postcss-modules-scope@3.2.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA=="],
"postcss-modules-values": ["postcss-modules-values@4.0.0", "", { "dependencies": { "icss-utils": "^5.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ=="],
"postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
"remotion": ["remotion@4.0.483", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-lf4xq4Twn75TQeTavFLrTE4zdck7EKBSx07ZrPv4om40Sist+3G0kq7NNzSjMeJ6G2Wx6vI+0oICD/vv4sGWtg=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
"semver": ["semver@7.5.3", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="],
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"style-loader": ["style-loader@4.0.0", "", { "peerDependencies": { "webpack": "^5.27.0" } }, "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="],
"terser-webpack-plugin": ["terser-webpack-plugin@5.6.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"to-fast-properties": ["to-fast-properties@2.0.0", "", {}, "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog=="],
"tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"watchpack": ["watchpack@2.5.2", "", { "dependencies": { "graceful-fs": "^4.1.2" } }, "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg=="],
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
"webpack": ["webpack@5.105.0", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.19.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.5.1", "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw=="],
"webpack-sources": ["webpack-sources@3.5.0", "", {}, "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ=="],
"whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"css-loader/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

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

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

+156
View File
@@ -0,0 +1,156 @@
// Verified against the production opencode-stats PlanetScale DB.
// Scope: tier='Go' (OpenCode Go), dataset='zen', client='all', source='all', grain='day'.
// metric = total_tokens. Daily token volume per model; GLM-5.2 (zhipu) launched Jun 17.
// Segments per day (tokens): glm-5.2, deepseek-v4-flash, deepseek-v4-pro, minimax-m3, all others.
export type Day = {
date: string
total: number
glm: number
dsf: number
dsp: number
mm: number
others: number
}
export const days: Day[] = [
{
date: "Jun 12",
total: 2_283_799_449_383,
glm: 0,
dsf: 1_176_701_653_509,
dsp: 569_527_034_307,
mm: 159_016_250_684,
others: 378_554_510_883,
},
{
date: "Jun 13",
total: 2_008_462_388_420,
glm: 0,
dsf: 995_338_131_997,
dsp: 445_817_536_548,
mm: 211_743_241_967,
others: 355_563_477_908,
},
{
date: "Jun 14",
total: 2_007_785_405_251,
glm: 0,
dsf: 983_954_176_228,
dsp: 428_151_999_341,
mm: 262_476_527_930,
others: 333_202_701_752,
},
{
date: "Jun 15",
total: 2_694_736_103_062,
glm: 0,
dsf: 1_255_893_953_859,
dsp: 632_223_338_376,
mm: 352_507_442_991,
others: 454_111_367_836,
},
{
date: "Jun 16",
total: 2_838_153_758_908,
glm: 0,
dsf: 1_336_625_283_800,
dsp: 676_480_415_730,
mm: 305_268_829_013,
others: 519_779_230_365,
},
{
date: "Jun 17",
total: 2_778_964_711_109,
glm: 70_095_977_043,
dsf: 1_339_831_523_555,
dsp: 660_414_395_220,
mm: 251_302_096_157,
others: 457_320_719_134,
},
{
date: "Jun 18",
total: 2_806_992_430_656,
glm: 201_130_231_172,
dsf: 1_295_599_996_869,
dsp: 595_665_008_776,
mm: 322_205_104_324,
others: 392_392_089_515,
},
{
date: "Jun 19",
total: 2_419_611_630_232,
glm: 199_086_413_910,
dsf: 1_115_750_468_802,
dsp: 475_965_869_304,
mm: 303_586_698_735,
others: 325_222_179_481,
},
{
date: "Jun 20",
total: 2_188_278_916_865,
glm: 193_931_516_396,
dsf: 1_050_194_681_012,
dsp: 395_303_435_278,
mm: 281_998_000_337,
others: 266_851_283_842,
},
{
date: "Jun 21",
total: 2_042_309_961_344,
glm: 181_894_043_118,
dsf: 985_164_570_580,
dsp: 368_194_079_542,
mm: 259_812_551_324,
others: 247_244_716_780,
},
{
date: "Jun 22",
total: 2_893_934_325_663,
glm: 301_759_048_475,
dsf: 1_298_124_282_989,
dsp: 581_012_596_194,
mm: 371_581_117_839,
others: 341_457_280_166,
},
{
date: "Jun 23",
total: 3_109_009_321_480,
glm: 282_277_235_158,
dsf: 1_423_571_678_821,
dsp: 627_374_654_587,
mm: 429_416_300_508,
others: 346_369_452_406,
},
{
date: "Jun 24",
total: 2_939_149_971_595,
glm: 256_497_442_533,
dsf: 1_373_583_023_234,
dsp: 601_270_997_775,
mm: 391_586_493_231,
others: 316_212_014_822,
},
{
date: "Jun 25",
total: 3_029_641_552_948,
glm: 256_279_657_734,
dsf: 1_481_084_002_776,
dsp: 602_077_167_287,
mm: 375_985_302_874,
others: 314_215_422_277,
},
]
export const launchIndex = 5 // Jun 17, first day of GLM-5.2 usage
// GLM-5.2 weekly token volume, Jun 19-25 (sum of glm): 1,671,725,357,324 = 1.67T
export const glmWeekTokensT = 1.672
// stacked segments, bottom -> top. GLM-5.2 is the hero (blue); the rest are the field it cut into.
export const segments = [
{ key: "glm", label: "GLM-5.2", color: "#3b5cf6", hero: true },
{ key: "dsf", label: "deepseek-v4-flash", color: "#9ca3ad" },
{ key: "dsp", label: "deepseek-v4-pro", color: "#b3b9c1" },
{ key: "mm", label: "minimax-m3", color: "#c8cdd3" },
{ key: "others", label: "other models", color: "#dde0e4" },
] as const
+185
View File
@@ -0,0 +1,185 @@
import React from "react"
import { AbsoluteFill, Easing, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"
// stats.opencode.ai design tokens (light theme)
const c = {
bg: "#ffffff",
ink: "#161616",
muted: "#5c5c5c",
faint: "#808080",
line: "#e6e6e6",
dot: "#e4e4e4",
gray: "#aab0b8",
accent: "#3b5cf6",
accentHi: "#5b78ff",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
const DOT_MASK =
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")"
// verified: OpenCode Go, week of Jun 22-28, 2026 (2026-W26). share = % of 19.64T total Go tokens.
const bars = [
{ label: "deepseek-v4-flash", share: 48.3, hero: true },
{ label: "deepseek-v4-pro", share: 19.4 },
{ label: "minimax-m3", share: 13.0 },
{ label: "glm-5.2", share: 8.3 },
{ label: "mimo-v2.5", share: 4.3 },
{ label: "kimi-k2.7-code", share: 2.6 },
{ label: "other models", share: 4.1 },
]
function DataWordmark({ height = 30 }: { height?: number }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.ink }}>
<path opacity="0.2" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
export function FlashShare() {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const grow = (i: number) =>
Math.min(
1,
Math.max(0, spring({ frame: frame - 18 - i * 7, fps, config: { damping: 18, stiffness: 120, mass: 0.6 } })),
)
return (
<AbsoluteFill style={{ background: c.bg, color: c.ink, fontFamily: MONO, padding: 72, boxSizing: "border-box" }}>
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.faint, letterSpacing: 1 }}>JUN 2228, 2026</div>
</div>
{/* headline (static) */}
<div style={{ marginTop: 50 }}>
<div style={{ fontSize: 23, fontWeight: 600, color: c.muted, letterSpacing: 2 }}>
OPENCODE GO · SHARE OF TOKENS
</div>
<div
style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 24, marginTop: 14 }}
>
<div style={{ fontSize: 62, fontWeight: 600, letterSpacing: -2, lineHeight: 1 }}>DeepSeek V4 Flash</div>
<div
style={{
fontSize: 88,
fontWeight: 600,
letterSpacing: -2,
lineHeight: 1,
color: c.accent,
fontVariantNumeric: "tabular-nums",
}}
>
48%
</div>
</div>
</div>
{/* bar chart */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", gap: 16 }}>
{bars.map((b, i) => {
const g = grow(i)
const pct = b.share * g
return (
<div key={b.label} style={{ display: "flex", alignItems: "center", gap: 18 }}>
<div
style={{
width: 268,
fontSize: 23,
fontWeight: b.hero ? 600 : 500,
color: b.hero ? c.ink : c.muted,
textAlign: "right",
whiteSpace: "nowrap",
}}
>
{b.label}
</div>
<div style={{ position: "relative", flex: 1, height: 48 }}>
{/* dotted 100% track — height is a multiple of the 12px tile, anchored bottom, so dots never clip */}
<div
style={{
position: "absolute",
inset: 0,
background: c.dot,
WebkitMaskImage: DOT_MASK,
maskImage: DOT_MASK,
WebkitMaskSize: "12px 12px",
maskSize: "12px 12px",
WebkitMaskRepeat: "repeat",
maskRepeat: "repeat",
WebkitMaskPosition: "left bottom",
maskPosition: "left bottom",
}}
/>
{/* fill */}
<div
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: `${pct}%`,
background: b.hero ? c.accent : c.gray,
borderRight: b.hero ? `2px solid ${c.accentHi}` : "none",
}}
/>
</div>
<div
style={{
width: 84,
fontSize: 24,
fontWeight: b.hero ? 600 : 500,
color: b.hero ? c.accent : c.muted,
textAlign: "right",
fontVariantNumeric: "tabular-nums",
}}
>
{Math.round(pct)}%
</div>
</div>
)
})}
</div>
{/* footer */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 24,
paddingTop: 22,
borderTop: `1px solid ${c.line}`,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ display: "inline-flex", alignItems: "center", gap: 9, color: c.muted }}>
<span style={{ width: 13, height: 13, background: c.accent, display: "inline-block" }} />
DeepSeek V4 Flash · 9.48T tokens · 83.6M requests
</div>
<div style={{ color: c.ink }}>opencode.ai/data</div>
</div>
</div>
</AbsoluteFill>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { Composition, registerRoot } from "remotion"
import { GLM52Rise } from "./video"
import { NZSheep } from "./sheep"
import { NovelTokens } from "./novel"
import { FlashShare } from "./flash"
import { MiniMaxClimb } from "./minimax"
import { JuneTotals } from "./june"
function Root() {
return (
<>
<Composition id="GLM52Rise" component={GLM52Rise} durationInFrames={240} fps={30} width={1080} height={1080} />
<Composition id="NZSheep" component={NZSheep} durationInFrames={150} fps={30} width={1080} height={1080} />
<Composition
id="NovelTokens"
component={NovelTokens}
durationInFrames={150}
fps={30}
width={1080}
height={1080}
/>
<Composition id="FlashShare" component={FlashShare} durationInFrames={165} fps={30} width={1080} height={1080} />
<Composition
id="MiniMaxClimb"
component={MiniMaxClimb}
durationInFrames={165}
fps={30}
width={1080}
height={1080}
/>
<Composition id="JuneTotals" component={JuneTotals} durationInFrames={1} fps={30} width={1080} height={1080} />
</>
)
}
registerRoot(Root)
+144
View File
@@ -0,0 +1,144 @@
import React from "react"
import { AbsoluteFill } from "remotion"
const c = {
bg: "#ffffff",
ink: "#161616",
muted: "#5c5c5c",
faint: "#808080",
line: "#e6e6e6",
dot: "#dcdcdc",
accent: "#3b5cf6",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
const DOT_MASK =
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")"
// verified: OpenCode Go (tier=Go, dataset=zen), June 1-30, 2026.
// 72.78T tokens · 651.4M requests · 11.42M sessions -> rounded headline figures.
function DataWordmark({ height = 30 }: { height?: number }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.ink }}>
<path opacity="0.2" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
function DotBand() {
return (
<div
style={{
height: 12,
background: c.dot,
WebkitMaskImage: DOT_MASK,
maskImage: DOT_MASK,
WebkitMaskSize: "12px 12px",
maskSize: "12px 12px",
WebkitMaskRepeat: "repeat",
maskRepeat: "repeat",
WebkitMaskPosition: "left top",
maskPosition: "left top",
}}
/>
)
}
function Metric({ value, label }: { value: string; label: string }) {
return (
<div>
<div
style={{
fontSize: 92,
fontWeight: 600,
letterSpacing: -3,
lineHeight: 1,
fontVariantNumeric: "tabular-nums",
marginLeft: -8, // align the glyph's visual left edge to the column
}}
>
{value}
</div>
<div style={{ marginTop: 16, fontSize: 24, fontWeight: 500, color: c.muted, letterSpacing: 1 }}>{label}</div>
</div>
)
}
export function JuneTotals() {
return (
<AbsoluteFill style={{ background: c.bg, color: c.ink, fontFamily: MONO, padding: 72, boxSizing: "border-box" }}>
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 22 }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.faint, letterSpacing: 1 }}>MONTHLY RECAP</div>
</div>
<DotBand />
{/* hero */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "center" }}>
<div style={{ fontSize: 25, fontWeight: 600, color: c.muted, letterSpacing: 3 }}>OPENCODE GO · JUNE 2026</div>
<div style={{ marginTop: 14 }}>
<div
style={{
fontSize: 300,
fontWeight: 600,
letterSpacing: -10,
lineHeight: 0.82,
color: c.accent,
marginLeft: -22,
}}
>
73T
</div>
<div style={{ fontSize: 50, fontWeight: 600, color: c.ink, marginTop: 10 }}>tokens processed</div>
</div>
{/* supporting metrics */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
marginTop: 60,
paddingTop: 40,
borderTop: `1px solid ${c.line}`,
}}
>
<Metric value="650M" label="requests" />
<Metric value="11M" label="sessions" />
</div>
</div>
{/* footer */}
<DotBand />
<div
style={{
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
marginTop: 22,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ color: c.ink }}>opencode.ai/data</div>
</div>
</div>
</AbsoluteFill>
)
}
+201
View File
@@ -0,0 +1,201 @@
import React from "react"
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"
const c = {
bg: "#ffffff",
ink: "#161616",
muted: "#5c5c5c",
faint: "#808080",
line: "#e6e6e6",
dot: "#e4e4e4",
accent: "#3b5cf6",
accentHi: "#5b78ff",
accentDim: "#aebcf3",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
const DOT_MASK =
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")"
// verified: minimax-m3, OpenCode Go (tier=Go, dataset=zen), weekly total_tokens.
// W26 2.559T is +23.2% / +482.3B vs W25 2.077T.
const weeks = [
{ label: "May 25", t: 0.008 },
{ label: "Jun 1", t: 0.429 },
{ label: "Jun 8", t: 1.192 },
{ label: "Jun 15", t: 2.077 },
{ label: "Jun 22", t: 2.559, latest: true },
]
const AXIS_MAX = 3.0
function DataWordmark({ height = 30 }: { height?: number }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.ink }}>
<path opacity="0.2" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
const CHART_H = 420 // multiple of 12 so the dotted tracks never clip
export function MiniMaxClimb() {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const grow = (i: number) =>
Math.min(
1,
Math.max(0, spring({ frame: frame - 22 - i * 9, fps, config: { damping: 18, stiffness: 110, mass: 0.6 } })),
)
return (
<AbsoluteFill style={{ background: c.bg, color: c.ink, fontFamily: MONO, padding: 72, boxSizing: "border-box" }}>
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.faint, letterSpacing: 1 }}>JUN 2228, 2026</div>
</div>
{/* headline (static) */}
<div style={{ marginTop: 50 }}>
<div style={{ fontSize: 23, fontWeight: 600, color: c.muted, letterSpacing: 2 }}>
OPENCODE GO · WEEKLY TOKENS
</div>
<div
style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 24, marginTop: 14 }}
>
<div style={{ fontSize: 62, fontWeight: 600, letterSpacing: -2, lineHeight: 1 }}>MiniMax M3</div>
<div style={{ textAlign: "right", flexShrink: 0 }}>
<div
style={{
fontSize: 84,
fontWeight: 600,
letterSpacing: -2,
lineHeight: 1,
color: c.accent,
fontVariantNumeric: "tabular-nums",
}}
>
+23.2%
</div>
<div style={{ fontSize: 19, fontWeight: 500, color: c.muted, letterSpacing: 1, marginTop: 6 }}>
WEEK OVER WEEK
</div>
</div>
</div>
</div>
{/* column chart */}
<div style={{ flex: 1, display: "flex", alignItems: "flex-end", marginTop: 30 }}>
<div style={{ width: "100%", display: "flex", alignItems: "flex-end", gap: 30 }}>
{weeks.map((w, i) => {
const g = grow(i)
const h = Math.round((w.t / AXIS_MAX) * CHART_H * g)
return (
<div key={w.label} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center" }}>
{/* value + bar */}
<div style={{ position: "relative", width: "100%", height: CHART_H }}>
{/* dotted track */}
<div
style={{
position: "absolute",
inset: 0,
background: c.dot,
WebkitMaskImage: DOT_MASK,
maskImage: DOT_MASK,
WebkitMaskSize: "12px 12px",
maskSize: "12px 12px",
WebkitMaskRepeat: "repeat",
maskRepeat: "repeat",
WebkitMaskPosition: "left bottom",
maskPosition: "left bottom",
}}
/>
{/* fill */}
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: h,
background: w.latest ? c.accent : c.accentDim,
borderTop: w.latest ? `3px solid ${c.accentHi}` : "none",
}}
/>
{/* value label */}
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: h + 10,
textAlign: "center",
fontSize: 26,
fontWeight: w.latest ? 600 : 500,
color: w.latest ? c.accent : c.muted,
fontVariantNumeric: "tabular-nums",
opacity: interpolate(g, [0.5, 1], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
}),
}}
>
{w.t.toFixed(2)}T
</div>
</div>
{/* week label */}
<div
style={{
marginTop: 14,
fontSize: 18,
fontWeight: 500,
color: w.latest ? c.ink : c.faint,
}}
>
{w.label}
</div>
</div>
)
})}
</div>
</div>
{/* footer */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 22,
paddingTop: 22,
borderTop: `1px solid ${c.line}`,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ display: "inline-flex", alignItems: "center", gap: 9, color: c.muted }}>
<span style={{ width: 13, height: 13, background: c.accent, display: "inline-block" }} />
2.56T tokens last week · +482.3B added
</div>
<div style={{ color: c.ink }}>opencode.ai/data</div>
</div>
</div>
</AbsoluteFill>
)
}
+135
View File
@@ -0,0 +1,135 @@
import React from "react"
import { AbsoluteFill, Easing, Img, interpolate, staticFile, useCurrentFrame } from "remotion"
const c = {
white: "#ffffff",
dim: "rgba(255,255,255,0.74)",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
// verified: OpenCode Go, week of Jun 22-28, 2026 (2026-W26)
// 19,642,742,937,105 tokens / 173,651,197 requests = 113,116 tokens/request
const AVG = 113116
const K = Math.round(AVG / 1000) // 113
const nf = new Intl.NumberFormat("en-US")
function DataWordmark({ height = 30 }: { height?: number }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.white }}>
<path opacity="0.35" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
export function NovelTokens() {
const frame = useCurrentFrame()
const k = Math.round(
K *
interpolate(frame, [18, 92], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
}),
)
const zoom = interpolate(frame, [0, 150], [1.06, 1.12], {
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.quad),
})
return (
<AbsoluteFill style={{ background: "#0c0c0c", overflow: "hidden" }}>
<Img
src={staticFile("book.jpg")}
style={{
position: "absolute",
width: "100%",
height: "100%",
objectFit: "cover",
objectPosition: "center 34%",
transform: `scale(${zoom})`,
transformOrigin: "center 30%",
}}
/>
{/* legibility scrims */}
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(to bottom, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 20%, rgba(0,0,0,0.1) 44%, rgba(0,0,0,0.86) 100%)",
}}
/>
<div
style={{
position: "absolute",
inset: 0,
boxSizing: "border-box",
padding: 64,
color: c.white,
fontFamily: MONO,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.dim, letterSpacing: 1 }}>JUN 2228, 2026</div>
</div>
{/* bottom block */}
<div>
<div style={{ fontSize: 23, fontWeight: 600, color: c.dim, letterSpacing: 2, marginBottom: 8 }}>
OPENCODE GO · LAST WEEK
</div>
<div
style={{
fontSize: 168,
fontWeight: 600,
lineHeight: 0.92,
letterSpacing: -5,
fontVariantNumeric: "tabular-nums",
}}
>
{k}K
</div>
<div style={{ fontSize: 50, fontWeight: 600, letterSpacing: -1, marginTop: 4 }}>tokens per request</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 30,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ color: c.dim }}>{nf.format(AVG)} tokens / request · last week</div>
<div style={{ color: c.white }}>opencode.ai/data</div>
</div>
</div>
</div>
</AbsoluteFill>
)
}
+139
View File
@@ -0,0 +1,139 @@
import React from "react"
import { AbsoluteFill, Easing, Img, interpolate, staticFile, useCurrentFrame } from "remotion"
const c = {
white: "#ffffff",
dim: "rgba(255,255,255,0.72)",
faint: "rgba(255,255,255,0.55)",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
// verified: NZ OpenCode Go, week of Jun 22-28, 2026 (2026-W26)
const TOKENS = 40_915_594_381 // 40.9B
const SHEEP = 23_600_000 // 23.6M
const PER_SHEEP = Math.round(TOKENS / SHEEP) // 1,734
const nf = new Intl.NumberFormat("en-US")
// the correct opencode "DATA" wordmark (white, over photo)
function DataWordmark({ height = 30 }: { height?: number }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.white }}>
<path opacity="0.35" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
export function NZSheep() {
const frame = useCurrentFrame()
const count = Math.round(
PER_SHEEP *
interpolate(frame, [18, 90], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
}),
)
// slow Ken Burns push-in (scale up only — never reveals an edge)
const zoom = interpolate(frame, [0, 150], [1.06, 1.12], {
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.quad),
})
return (
<AbsoluteFill style={{ background: "#0c0c0c", overflow: "hidden" }}>
{/* the sheep, staring */}
<Img
src={staticFile("sheep.jpg")}
style={{
position: "absolute",
width: "100%",
height: "100%",
objectFit: "cover",
objectPosition: "center 38%",
transform: `scale(${zoom})`,
transformOrigin: "center 35%",
}}
/>
{/* legibility scrims */}
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0) 22%, rgba(0,0,0,0) 48%, rgba(0,0,0,0.78) 100%)",
}}
/>
{/* content */}
<div
style={{
position: "absolute",
inset: 0,
boxSizing: "border-box",
padding: 64,
color: c.white,
fontFamily: MONO,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.dim, letterSpacing: 1 }}>JUN 2228, 2026</div>
</div>
{/* bottom block */}
<div>
<div style={{ fontSize: 23, fontWeight: 600, color: c.dim, letterSpacing: 2, marginBottom: 8 }}>
OPENCODE GO · NEW ZEALAND
</div>
<div
style={{
fontSize: 168,
fontWeight: 600,
lineHeight: 0.92,
letterSpacing: -5,
fontVariantNumeric: "tabular-nums",
}}
>
{nf.format(count)}
</div>
<div style={{ fontSize: 50, fontWeight: 600, letterSpacing: -1, marginTop: 4 }}>tokens per sheep</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 34,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ color: c.dim }}>40.9B tokens ÷ 23.6M sheep · last week</div>
<div style={{ color: c.white }}>opencode.ai/data</div>
</div>
</div>
</div>
</AbsoluteFill>
)
}
+254
View File
@@ -0,0 +1,254 @@
import React from "react"
import { AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion"
import { days, launchIndex, glmWeekTokensT, segments } from "./data"
// stats.opencode.ai design tokens (light theme)
const c = {
bg: "#ffffff",
ink: "#161616",
muted: "#5c5c5c",
faint: "#808080",
line: "#e6e6e6",
dot: "#ededed",
accent: "#3b5cf6",
accentHi: "#5b78ff",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
const W = 1080
const DOT_MASK =
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")"
const field = segments.filter((s) => !s.hero)
const glmColor = segments.find((s) => s.hero)!.color
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
// the correct opencode "DATA" wordmark (from stats.opencode.ai header)
function DataWordmark({ height = 30, color = c.ink }: { height?: number; color?: string }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color }}>
<path opacity="0.2" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
export function GLM52Rise() {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
// ---------- virtual camera ----------
// open zoomed-in on the field, pan right while the blue fills, then pull back to reveal.
const K = [0, 32, 150, 206, 240]
const ease = Easing.inOut(Easing.cubic)
const opt = { extrapolateLeft: "clamp" as const, extrapolateRight: "clamp" as const, easing: ease }
const s = interpolate(frame, K, [1.82, 1.72, 1.72, 1.0, 1.0], opt)
let fx = interpolate(frame, K, [420, 438, 760, 540, 540], opt)
let fy = interpolate(frame, K, [664, 664, 664, 540, 540], opt)
// keep the framing inside the 1080 canvas so edges never reveal black
fx = clamp(fx, 540 / s, W - 540 / s)
fy = clamp(fy, 540 / s, W - 540 / s)
const camera = `translate(${540 - fx * s}px, ${540 - fy * s}px) scale(${s})`
// ---------- blue sweep (synced to the pan) ----------
const p = interpolate(frame, [32, 150], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease })
const revealAmount = p * (days.length - launchIndex) + 0.35
const fillOf = (i: number) => clamp(revealAmount - (i - launchIndex), 0, 1)
// token number climbs as the camera pulls back and the headline re-enters frame
const tokensT =
glmWeekTokensT *
interpolate(frame, [150, 202], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
})
// chart geometry
const chartH = 440
const chartW = 936
const gap = 14
const EXAGGERATE = 2.876 // broken y-axis: GLM-5.2 magnified ~2.9x for emphasis
return (
<AbsoluteFill style={{ background: c.bg, overflow: "hidden" }}>
<div
style={{
position: "absolute",
width: W,
height: W,
background: c.bg,
transformOrigin: "0 0",
transform: camera,
}}
>
<div
style={{
width: W,
height: W,
boxSizing: "border-box",
padding: 72,
color: c.ink,
fontFamily: MONO,
display: "flex",
flexDirection: "column",
}}
>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.faint, letterSpacing: 1 }}>JUN 1225, 2026</div>
</div>
{/* headline (static) */}
<div style={{ marginTop: 52 }}>
<div style={{ fontSize: 92, fontWeight: 600, lineHeight: 0.98, letterSpacing: -2 }}>
GLM-5.2 <span style={{ color: c.accent }}>broke out</span>
</div>
<div style={{ marginTop: 22, fontSize: 30, fontWeight: 500, color: c.muted }}>
From 0 to <span style={{ color: c.ink }}>{tokensT.toFixed(2)}T tokens</span> in a week.
</div>
</div>
{/* stacked chart */}
<div style={{ flex: 1, display: "flex", alignItems: "flex-end", marginTop: 40 }}>
<div
style={{
position: "relative",
width: chartW,
height: chartH,
margin: "0 auto",
borderBottom: `2px solid ${c.ink}`,
boxSizing: "border-box",
}}
>
{/* faint dotted backdrop */}
<div
style={{
position: "absolute",
left: -8,
right: -8,
top: -8,
bottom: 0,
background: c.dot,
WebkitMaskImage: DOT_MASK,
maskImage: DOT_MASK,
WebkitMaskSize: "12px 12px",
maskSize: "12px 12px",
WebkitMaskRepeat: "repeat",
maskRepeat: "repeat",
}}
/>
{/* columns */}
<div style={{ position: "absolute", inset: 0, display: "flex", gap, alignItems: "flex-end" }}>
{days.map((d, i) => {
const glmShare = d.glm / d.total
const blueH = Math.round(glmShare * EXAGGERATE * chartH)
const filled = Math.round(blueH * fillOf(i))
const slotGap = filled > 2 ? 5 : 0
const fieldH = chartH - filled - slotGap
const fieldTotal = d.dsf + d.dsp + d.mm + d.others
return (
<div key={i} style={{ position: "relative", flex: 1, height: chartH }}>
{/* gray field of other models (already in place) */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: fieldH,
display: "flex",
flexDirection: "column-reverse",
}}
>
{field.map((seg) => (
<div
key={seg.key}
style={{
height: Math.round(((d[seg.key as keyof typeof d] as number) / fieldTotal) * fieldH),
background: seg.color,
borderTop: `2px solid ${c.bg}`,
}}
/>
))}
</div>
{/* GLM-5.2, animates in */}
{filled > 2 && (
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: filled,
background: glmColor,
borderTop: `2px solid ${c.accentHi}`,
}}
/>
)}
</div>
)
})}
</div>
</div>
</div>
{/* day axis */}
<div style={{ display: "flex", gap, width: chartW, margin: "14px auto 0" }}>
{days.map((d, i) => (
<div
key={i}
style={{
flex: 1,
textAlign: "center",
fontSize: 15,
fontWeight: 500,
color: i === days.length - 1 ? c.ink : c.faint,
}}
>
{i === 0 || i === launchIndex || i === days.length - 1 ? d.date : ""}
</div>
))}
</div>
{/* footer */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 28,
paddingTop: 22,
borderTop: `1px solid ${c.line}`,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ display: "inline-flex", alignItems: "center", gap: 9, color: c.muted }}>
<span style={{ width: 13, height: 13, background: c.accent, display: "inline-block" }} />
GLM-5.2
</div>
<div style={{ color: c.ink }}>opencode.ai/data</div>
</div>
</div>
</div>
</AbsoluteFill>
)
}
+110 -67
View File
@@ -24,12 +24,12 @@
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "catalog:",
"turbo": "2.8.13",
"turbo": "2.10.2",
},
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
@@ -93,7 +93,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.17.13",
"version": "1.17.15",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -139,9 +139,23 @@
"effect",
],
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.17.15",
"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",
"version": "1.17.15",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -177,7 +191,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -204,7 +218,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -226,7 +240,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -250,7 +264,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -270,7 +284,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.17.13",
"version": "1.17.15",
"bin": {
"opencode": "./bin/opencode",
},
@@ -294,7 +308,7 @@
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.82",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -364,7 +378,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -418,7 +432,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -432,7 +446,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"effect": "catalog:",
},
@@ -444,7 +458,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -476,7 +490,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -492,7 +506,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@@ -523,7 +537,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -542,7 +556,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.17.13",
"version": "1.17.15",
"bin": {
"opencode": "./bin/opencode",
},
@@ -568,7 +582,7 @@
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.82",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/opentelemetry": "catalog:",
@@ -579,6 +593,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -672,7 +687,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
@@ -689,9 +704,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.3.4",
"@opentui/keymap": ">=0.3.4",
"@opentui/solid": ">=0.3.4",
"@opentui/core": ">=0.4.3",
"@opentui/keymap": ">=0.4.3",
"@opentui/solid": ">=0.4.3",
},
"optionalPeers": [
"@opentui/core",
@@ -748,7 +763,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -763,7 +778,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -778,7 +793,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -822,7 +837,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -835,7 +850,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
@@ -868,7 +883,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -887,7 +902,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -928,7 +943,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -955,7 +970,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1006,7 +1021,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.17.13",
"version": "1.17.15",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1049,6 +1064,7 @@
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
@@ -1058,7 +1074,6 @@
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
},
@@ -1081,9 +1096,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.4",
"@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.3",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1196,7 +1211,7 @@
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.102", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.56", "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NeQyOR7OCqDMgaLS4uNX/ep/HrwUzzFYLzXQSRoqLy2jsnqxAJhsgltRwAwf+ADjyPBIAKEOestWnIQA+LrLrQ=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -1924,6 +1939,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"],
@@ -2014,27 +2031,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
"@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="],
"@opentui/core": ["@opentui/core@0.4.3", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.3", "@opentui/core-darwin-x64": "0.4.3", "@opentui/core-linux-arm64": "0.4.3", "@opentui/core-linux-arm64-musl": "0.4.3", "@opentui/core-linux-x64": "0.4.3", "@opentui/core-linux-x64-musl": "0.4.3", "@opentui/core-win32-arm64": "0.4.3", "@opentui/core-win32-x64": "0.4.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-rrJfAk13tALDqldYjhc78eWQ+aKq1iknJgffIOg3OwyZoqQo+p6gtuqyhmWvXIfQzlNUbpgpCPcxbXlhMnlaHQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p5+7AAxpxGuDGagyQfewKtmTFnN7THvTVY4FyKqUtJomNaHdQXPHztapNNzMx0DGWbwOUbVKzpL+yc3CZY3chQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-+fh0vEUE0lwVC7RW5ijYLRlTLp5NfvCRj8SzxDVd7IL2j2ssB6YXcfIbXq2EW7UGnrejwPRXf1tgUrIXW9KmOw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gl6qA5QJy6u8Cbt7gOtHbhhfMZ4qQDb0kEwFXHcMGmbnKzz4OHoq74D6tNjyvSQB9saoC7C6C0tvn2DcJOuNog=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8p8g8/AEq/xFGpQ7XcIFKcAqjc0QwsZcv+Ll9RbCDpUA56FGH6jfLDir0KYTNTgYXJTIrBIENI9K46VuxMUMQA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-dXpJitiZdYE3hq2Pvx6e9I0uPQSOcnaLLp1pDgWAHv+3kvKSHEX//9Yr/pV/Ua6qqT7p+2D/K4vXNap/NKVo2w=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/QiFpCrpU2O7vy8QYmLIQYbvAtKDgmqcVjR7dGtqSzkiQk3ktNJoo5RozG7ueXnjung1Wp0nKldKxo2Csg/OrA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Mx2zuOjrhm/z2SDS6RExIyjP/SnN/8QhhagxURUw0jQi/NssGSeAllu1cBAFFnhobJL5QLTE4FU4CRhUK9svgg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NuoqvWKGXaYnmlqvu7Gg2lLI6yVMnS9OfWBvxp+7Q+McSgHFSTQmYBXaPpvQ8HikpQXE1nCeMPtuSG4PdZHe2w=="],
"@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="],
"@opentui/keymap": ["@opentui/keymap@0.4.3", "", { "dependencies": { "@opentui/core": "0.4.3" }, "peerDependencies": { "@opentui/react": "0.4.3", "@opentui/solid": "0.4.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-sinX0pyQBRrEvo89PSSUbSUDIYpL3xWo81VEfec58VFoVRB5FG48/deAtvRTQfJ8w1kgbzN8hzdOXdSm61zBmw=="],
"@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="],
"@opentui/solid": ["@opentui/solid@0.4.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-RcV0+S8HMdXOASyr7HmJUBuTUIaFPzAxMDa44VftS5C2JUgrmAuWo0Njv1q3TWRB1owjHnyKhEfWGKq7A82wxw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2774,6 +2791,18 @@
"@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-wBM3ObqOWnKUDmg7QfUFDkDHPFUAJmrYlYqmEM8jMPAPA/I6wRJIbWimeQUqhOiQ8xPKhzyWM+xaiUP0wz8FEQ=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/Cq0joWnuMjDPfhjbFP4sv+C/7gkQ415zlaO4XUzD5EZxbtrKgXKvuuydMvogG8GeUnN1aDltW71RlmEfpjbyw=="],
"@turbo/linux-64": ["@turbo/linux-64@2.10.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mMsf5IIhiKuceEXNstd25IbadjBXZ0amxzFOqliEzJX6HyeeHdBQPVSY583PWqYDyqM/FB8d5ZjkthfBSeuH3Q=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Wcng1i2kaKmXutmwxT9MUoYZvdaIekXAdlGr4+0TpgbhGLw7nDuEcRBFrxb5BbRoX1d1q8SpdRxLc45TvDZIdQ=="],
"@turbo/windows-64": ["@turbo/windows-64@2.10.2", "", { "os": "win32", "cpu": "x64" }, "sha512-SsNhM7Ho7EpAdwtrJKBOic9Hso23vu6Dp0gAfLOvUFjPzurr/sGQlXZEvr6z89ne4RDOypTwz5CBDrixpMKtXw=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gf+S7ICAdimT/n02bOuVWKvhHnct/HYjZg3oBNIz5hZ9ZyWHbQim9J3P5Qip8WpX0ksxF7eaBVziJCuLnjhqDg=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
@@ -3010,7 +3039,7 @@
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -3214,7 +3243,7 @@
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
@@ -5304,19 +5333,7 @@
"tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="],
"turbo": ["turbo@2.8.13", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.13", "turbo-darwin-arm64": "2.8.13", "turbo-linux-64": "2.8.13", "turbo-linux-arm64": "2.8.13", "turbo-windows-64": "2.8.13", "turbo-windows-arm64": "2.8.13" }, "bin": { "turbo": "bin/turbo" } }, "sha512-nyM99hwFB9/DHaFyKEqatdayGjsMNYsQ/XBNO6MITc7roncZetKb97MpHxWf3uiU+LB9c9HUlU3Jp2Ixei2k1A=="],
"turbo-darwin-64": ["turbo-darwin-64@2.8.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-PmOvodQNiOj77+Zwoqku70vwVjKzL34RTNxxoARjp5RU5FOj/CGiC6vcDQhNtFPUOWSAaogHF5qIka9TBhX4XA=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kI+anKcLIM4L8h+NsM7mtAUpElkCOxv5LgiQVQR8BASyDFfc8Efj5kCk3cqxuxOvIqx0sLfCX7atrHQ2kwuNJQ=="],
"turbo-linux-64": ["turbo-linux-64@2.8.13", "", { "os": "linux", "cpu": "x64" }, "sha512-j29KnQhHyzdzgCykBFeBqUPS4Wj7lWMnZ8CHqytlYDap4Jy70l4RNG46pOL9+lGu6DepK2s1rE86zQfo0IOdPw=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-OEl1YocXGZDRDh28doOUn49QwNe82kXljO1HXApjU0LapkDiGpfl3jkAlPKxEkGDSYWc8MH5Ll8S16Rf5tEBYg=="],
"turbo-windows-64": ["turbo-windows-64@2.8.13", "", { "os": "win32", "cpu": "x64" }, "sha512-717bVk1+Pn2Jody7OmWludhEirEe0okoj1NpRbSm5kVZz/yNN/jfjbxWC6ilimXMz7xoMT3IDfQFJsFR3PMANA=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-R819HShLIT0Wj6zWVnIsYvSNtRNj1q9VIyaUz0P24SMcLCbQZIm1sV09F4SDbg+KCCumqD2lcaR2UViQ8SnUJA=="],
"turbo": ["turbo@2.10.2", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.2", "@turbo/darwin-arm64": "2.10.2", "@turbo/linux-64": "2.10.2", "@turbo/linux-arm64": "2.10.2", "@turbo/windows-64": "2.10.2", "@turbo/windows-arm64": "2.10.2" }, "bin": { "turbo": "bin/turbo" } }, "sha512-wTExrNrRjB8qzIcg+ZLm0A3GFNLDsWNwdS/RBXB0FPrBDyzk3i96Yx+TxWZC7a0k1SIreFB8ciUbxjmEqTH8IQ=="],
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
@@ -5574,8 +5591,6 @@
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
@@ -5678,7 +5693,11 @@
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/xai/@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/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="],
"@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.13", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-ZPtVYt5QIJzOta1kdUiDuCx4HhFkvNPv/rvmZ2b1iXwybYjJsCnNYR4PAw4kW7rgVfDARvHXcU64efWuqNp6bw=="],
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bjYld/2KGPLt78kpqbya+fD4LYS7BqVQJyUjE3qAHrYB0FR2Q90BaWEVIBZaguTWXf/A8L6uG1zO1v9TxVlGWg=="],
"@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@@ -5692,6 +5711,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=="],
@@ -5902,6 +5923,8 @@
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
@@ -6124,6 +6147,8 @@
"ai-gateway-provider/@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=="],
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="],
"ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
@@ -6152,6 +6177,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=="],
@@ -6242,6 +6269,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=="],
@@ -6306,6 +6335,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=="],
@@ -6434,6 +6465,8 @@
"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=="],
"thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
@@ -6452,6 +6485,8 @@
"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=="],
@@ -6466,6 +6501,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=="],
@@ -6546,6 +6583,8 @@
"@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -6884,6 +6923,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=="],
@@ -6906,6 +6947,8 @@
"ai-gateway-provider/@ai-sdk/anthropic/@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=="],
"ai-gateway-provider/@ai-sdk/xai/@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=="],
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-ZmAIcG3wPgssKejLe/EMJOjPuMHCyRiiItRvSqIjaME=",
"aarch64-linux": "sha256-lbAHoVI/CvgdrfKZ/9dKZ/O7DxJvuWEOrDgcCuFGPBk=",
"aarch64-darwin": "sha256-Nrs4ijvzQ3J8AYigJSlsr66feUvLZ5iiYCfBdaZNJEc=",
"x86_64-darwin": "sha256-ltCaJSO7kZWuMOU49269xvboNVX35OJDFj0gXCDf62Y="
"x86_64-linux": "sha256-K0V7FyvgujbeAf0Ip4SCJxcy0GpSt/H+iEwTVhvwBRQ=",
"aarch64-linux": "sha256-lz2GhziKY9zL4vXb2/CmlXKrmVYPF5hPFD1ZpfDOqgM=",
"aarch64-darwin": "sha256-vfxsqozBq40tc1v6w8xvl1HIAif8gRn6oIYrLt6fZ1c=",
"x86_64-darwin": "sha256-X7GAwO+8A6vwqObIHFtloDDJZO8ZUTqmplFDg1zfots="
}
}
+6 -5
View File
@@ -19,6 +19,7 @@
"prepare": "husky",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"translate:app": "bun run script/translate-app.ts",
"test": "echo 'do not run tests from root' && exit 1"
},
"workspaces": {
@@ -39,9 +40,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.4",
"@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.3",
"@tanstack/solid-virtual": "3.13.28",
"@shikijs/stream": "4.2.0",
"ulid": "3.0.1",
@@ -104,7 +105,7 @@
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "catalog:",
"turbo": "2.8.13"
"turbo": "2.10.2"
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
@@ -146,7 +147,7 @@
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
@@ -48,6 +48,40 @@ test("does not reverse visible rows when the user wheels during shell remeasurem
await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1))
})
test("keeps moving upward while drag-selecting above the timeline", async ({ page }) => {
await setupTimeline(page, {
messages: history(80),
viewport: { width: 1400, height: 700 },
reducedMotion: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const text = page.getByText("History 79.", { exact: false })
await expect(text).toBeVisible()
await scroller.evaluate((element) => {
element.dataset.selectionLength = "0"
document.addEventListener("selectionchange", () => {
element.dataset.selectionLength = String(
Math.max(Number(element.dataset.selectionLength), window.getSelection()?.toString().length ?? 0),
)
})
})
const textBox = await text.boundingBox()
const scrollBox = await scroller.boundingBox()
expect(textBox).not.toBeNull()
expect(scrollBox).not.toBeNull()
if (!textBox || !scrollBox) return
await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2)
await page.mouse.down()
await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 })
await expect.poll(() => scroller.evaluate((element) => Number(element.dataset.selectionLength))).toBeGreaterThan(0)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeGreaterThan(500)
await page.mouse.up()
})
test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => {
const shellID = "prt_keyboard_01_shell"
const followingID = "prt_keyboard_02_following"
@@ -0,0 +1,312 @@
import type { Page } from "@playwright/test"
import { benchmark, expect } from "../benchmark"
import { setupTimelineBenchmark } from "./session-timeline-benchmark.fixture"
const changedLinesPerFile = 100
const linesPerSide = changedLinesPerFile / 2
const fileCounts = [1, 10, 100, 1_000, 10_000]
const filesPerDirectory = 100
const readyFrames = 3
const completionTimeoutMs = Number(process.env.REVIEW_PANE_COMPLETION_TIMEOUT_MS ?? 900_000)
type ReviewPaneScalingSample = {
observedAtMs: number
logicalRows: number
treeRows: number
fileRows: number
diffLines: number
header: string
ready: boolean
}
type ReviewPaneScalingProbe = {
startedAt?: number
firstTreeRowMs?: number
logicalTreeReadyMs?: number
firstDiffRenderMs?: number
stableReadyMs?: number
samples: ReviewPaneScalingSample[]
frameTimesMs: number[]
longTasks: { startTime: number; duration: number }[]
stop: () => void
}
benchmark.describe("performance: review pane scaling", () => {
for (const fileCount of fileCounts) {
const changedLines = fileCount * changedLinesPerFile
benchmark(
`${changedLines} changed lines across ${fileCount} ${fileCount === 1 ? "file" : "files"}`,
async ({ page, report }) => {
benchmark.setTimeout(1_200_000)
await page.emulateMedia({ reducedMotion: "reduce" })
const patchByteLimit = Number(process.env.REVIEW_PANE_PATCH_BYTE_LIMIT ?? Number.POSITIVE_INFINITY)
if (Number.isNaN(patchByteLimit) || patchByteLimit < 0)
throw new Error(`Invalid REVIEW_PANE_PATCH_BYTE_LIMIT: ${process.env.REVIEW_PANE_PATCH_BYTE_LIMIT}`)
const responseBody = JSON.stringify(createScalingDiffs(fileCount, patchByteLimit))
await setupTimelineBenchmark(page, {
historyTurns: 0,
eventBatch: 1,
newLayoutDesigns: true,
})
await page.route("**/vcs/diff**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: responseBody,
}),
)
const expectedRows = fileCount + 2 + Math.ceil(fileCount / filesPerDirectory)
const metrics = await measureReviewPaneLoad(page, {
expectedFile: reviewFile(0),
expectedRows,
})
const search = await measureBroadReviewSearch(page, fileCount)
expect(metrics.logicalRows).toBe(expectedRows)
expect(metrics.fileRows).toBeGreaterThan(0)
expect(metrics.treeRows).toBeGreaterThan(0)
expect(metrics.diffLines).toBeGreaterThan(0)
expect(search.logicalRows).toBe(fileCount)
expect(search.renderedRows).toBeGreaterThan(0)
report(
{ ...metrics, search },
{
fileCount,
changedLinesPerFile,
changedLines,
additions: changedLines / 2,
deletions: changedLines / 2,
patchLines: changedLines,
patchByteLimit: Number.isFinite(patchByteLimit) ? patchByteLimit : null,
payloadBytes: new TextEncoder().encode(responseBody).byteLength,
expectedRows,
},
)
},
)
}
})
async function measureBroadReviewSearch(page: Page, expectedRows: number) {
const filter = page.getByRole("searchbox", { name: "Filter files" })
await filter.evaluate((element) => {
element.addEventListener(
"input",
() => {
;(window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt = performance.now()
},
{ once: true, capture: true },
)
})
await filter.fill("file-")
return page.evaluate((expectedRows) => {
const startedAt = (window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt!
return new Promise<{ stableMs: number; logicalRows: number; renderedRows: number }>((resolve) => {
let previous = -1
let streak = 0
const sample = () => {
const tree = document.querySelector<HTMLElement>('#review-panel [data-component="file-tree-v2"]')
const rows = [...document.querySelectorAll<HTMLElement>('#review-panel [data-slot="file-tree-v2-row"]')]
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
const ready =
logicalRows === expectedRows && rows.length > 0 && rows.every((row) => row.textContent?.includes("file-"))
streak = ready && rows.length === previous ? streak + 1 : ready ? 1 : 0
previous = rows.length
if (streak >= 3) {
resolve({ stableMs: performance.now() - startedAt, logicalRows, renderedRows: rows.length })
return
}
requestAnimationFrame(sample)
}
requestAnimationFrame(sample)
})
}, expectedRows)
}
function createScalingDiffs(fileCount: number, patchByteLimit: number) {
const changes = Array.from({ length: linesPerSide }, (_, index) => {
const line = String(index).padStart(3, "0")
return `-export const value_${line} = "before"\n+export const value_${line} = "after"`
}).join("\n")
let patchBytes = 0
let capped = false
return Array.from({ length: fileCount }, (_, index) => {
const file = reviewFile(index)
const fullPatch = [
`diff --git a/${file} b/${file}`,
`--- a/${file}`,
`+++ b/${file}`,
`@@ -1,${linesPerSide} +1,${linesPerSide} @@`,
changes,
].join("\n")
if (index === 0 && fullPatch.length > patchByteLimit)
throw new Error(`REVIEW_PANE_PATCH_BYTE_LIMIT must include the active patch (${fullPatch.length} bytes)`)
const patch = !capped && patchBytes + fullPatch.length <= patchByteLimit ? fullPatch : emptyReviewPatch(file)
if (patch === fullPatch) patchBytes += fullPatch.length
else capped = true
return {
file,
patch,
additions: linesPerSide,
deletions: linesPerSide,
status: "modified" as const,
}
})
}
function emptyReviewPatch(file: string) {
return [`diff --git a/${file} b/${file}`, `--- a/${file}`, `+++ b/${file}`].join("\n")
}
function reviewFile(index: number) {
return `src/review/d${String(Math.floor(index / filesPerDirectory)).padStart(5, "0")}/file-${String(index).padStart(5, "0")}.ts`
}
async function measureReviewPaneLoad(page: Page, input: { expectedFile: string; expectedRows: number }) {
const toggle = page.getByRole("button", { name: "Toggle review" })
await expect(toggle).toBeVisible()
await toggle.evaluate((element) => element.setAttribute("data-review-pane-scaling-toggle", ""))
await installReviewPaneScalingProbe(page, input)
await toggle.click()
await page.waitForFunction(
() =>
(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe
?.stableReadyMs !== undefined,
undefined,
{ timeout: completionTimeoutMs },
)
return page.evaluate(() => {
const probe = (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe!
probe.stop()
const startedAt = probe.startedAt!
const final = probe.samples.at(-1)!
const resources = performance
.getEntriesByType("resource")
.filter((entry) => entry.name.includes("/vcs/diff")) as PerformanceResourceTiming[]
const resource = resources.at(-1)
const longTasks = probe.longTasks.filter(
(entry) => entry.startTime >= startedAt && entry.startTime <= startedAt + probe.stableReadyMs!,
)
const frameGaps = probe.frameTimesMs.map((time, index) => time - (probe.frameTimesMs[index - 1] ?? 0))
return {
firstTreeRowMs: probe.firstTreeRowMs ?? null,
logicalTreeReadyMs: probe.logicalTreeReadyMs ?? null,
firstDiffRenderMs: probe.firstDiffRenderMs ?? null,
stableReadyMs: probe.stableReadyMs ?? null,
responseStartMs: resource ? resource.responseStart - startedAt : null,
responseEndMs: resource ? resource.responseEnd - startedAt : null,
responseToStableMs: resource ? probe.stableReadyMs! - (resource.responseEnd - startedAt) : null,
treeRows: final.treeRows,
logicalRows: final.logicalRows,
fileRows: final.fileRows,
diffLines: final.diffLines,
samples: probe.samples.length,
maxFrameGapMs: Math.max(0, ...frameGaps),
longTaskCount: longTasks.length,
longTaskTotalMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
maxLongTaskMs: Math.max(0, ...longTasks.map((entry) => entry.duration)),
}
})
}
async function installReviewPaneScalingProbe(page: Page, input: { expectedFile: string; expectedRows: number }) {
await page.evaluate(
({ expectedFile, expectedRows, stableFrames }) => {
let running = true
let readyStreak = 0
const basename = expectedFile.split("/").at(-1)!
const longTaskObserver = PerformanceObserver.supportedEntryTypes.includes("longtask")
? new PerformanceObserver((list) => {
probe.longTasks.push(
...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })),
)
})
: undefined
const probe: ReviewPaneScalingProbe = {
samples: [],
frameTimesMs: [],
longTasks: [],
stop: () => {
running = false
longTaskObserver?.disconnect()
},
}
const sample = (time: number) => {
if (!running || probe.startedAt === undefined) return
const panel = document.querySelector<HTMLElement>("#review-panel")
const tree = panel?.querySelector<HTMLElement>('[data-component="file-tree-v2"]')
const rows = panel?.querySelectorAll('[data-slot="file-tree-v2-row"]') ?? []
const fileRows = panel?.querySelectorAll('button[data-slot="file-tree-v2-row"]') ?? []
const header =
panel?.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')?.textContent?.trim() ?? ""
const viewers = panel
? [...panel.querySelectorAll<HTMLElement>('[data-component="file"][data-mode="diff"]')]
: []
const diffLines = viewers.reduce(
(sum, viewer) =>
sum + (viewer.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0),
0,
)
const observedAtMs = time - probe.startedAt
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
const ready =
logicalRows === expectedRows &&
fileRows.length > 0 &&
header.includes(basename) &&
viewers.length === 1 &&
diffLines > 0
const previous = probe.samples.at(-1)
const stable =
ready &&
previous?.ready === true &&
previous.logicalRows === logicalRows &&
previous.treeRows === rows.length &&
previous.fileRows === fileRows.length &&
previous.diffLines === diffLines &&
previous.header === header
probe.frameTimesMs.push(observedAtMs)
probe.samples.push({
observedAtMs,
logicalRows,
treeRows: rows.length,
fileRows: fileRows.length,
diffLines,
header,
ready,
})
if (probe.firstTreeRowMs === undefined && rows.length > 0) probe.firstTreeRowMs = observedAtMs
if (probe.logicalTreeReadyMs === undefined && logicalRows === expectedRows)
probe.logicalTreeReadyMs = observedAtMs
if (probe.firstDiffRenderMs === undefined && diffLines > 0) probe.firstDiffRenderMs = observedAtMs
readyStreak = !ready ? 0 : stable ? readyStreak + 1 : 1
if (readyStreak === stableFrames) probe.stableReadyMs = observedAtMs
if (probe.stableReadyMs === undefined) requestAnimationFrame(sample)
}
longTaskObserver?.observe({ type: "longtask", buffered: true })
document.addEventListener(
"click",
(event) => {
const toggle = event.target instanceof Element ? event.target.closest("button") : undefined
if (!toggle?.hasAttribute("data-review-pane-scaling-toggle")) return
probe.startedAt = performance.now()
performance.mark("opencode.review-pane-scaling.click")
requestAnimationFrame(sample)
},
{ capture: true, once: true },
)
;(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe = probe
},
{ ...input, stableFrames: readyFrames },
)
}
@@ -0,0 +1,151 @@
import type { Page } from "@playwright/test"
import { expectSessionTitle } from "../../utils/waits"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
import { fixture } from "./session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
type ParentHydrationBenchmarkMode = "natural" | "candidate"
const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
const userID = "msg_parent_hydration_user"
const user = {
...fixture.messages[fixture.targetID][0]!,
info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
...part,
id: `prt_parent_hydration_user_${index}`,
messageID: userID,
})),
}
const assistantSeed = fixture.messages[fixture.targetID][3]!
const assistants = Array.from({ length: 14 }, (_, index) => {
const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
return {
...assistantSeed,
info: {
...assistantSeed.info,
id: messageID,
parentID: userID,
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
},
parts: assistantSeed.parts.map((part, partIndex) => ({
...part,
id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
messageID,
})),
}
})
const messages = [user, ...assistants]
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
const lastID = userID
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
benchmark.setTimeout(180_000)
const results = [] as Awaited<ReturnType<typeof trial>>[]
for (let run = 0; run < 5; run++) {
results.push(
await withBenchmarkPage(
browser,
`session-parent-hydration-${mode}-${run}`,
(page) => trial(page, mode),
testInfo,
),
)
}
const timing = results.map((result) => result.metrics.firstCorrectObservedMs!).sort((a, b) => a - b)
report(
{
results: results.map((result) => ({ ...result.metrics, historyGateCount: result.historyGateCount })),
summary: {
firstCorrectObservedMs: { min: timing[0], median: timing[2], max: timing.at(-1) },
blankSamples: results.map((result) => result.metrics.blankSamples),
requestCounts: {
list: results.map((result) => result.requestCounts.list),
parent: results.map((result) => result.requestCounts.parent),
},
historyGateCount: results.map((result) => result.historyGateCount),
},
},
{ mode },
)
})
async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
const requests: { type: "list" | "parent"; before?: string }[] = []
const history = mode === "candidate" ? Promise.withResolvers<void>() : undefined
let historyGates = 0
await mockOpenCodeServer(page, {
sessions: fixture.sessions.filter((session) => session.id === fixture.sourceID),
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
messageDelay: 50,
onMessages: (request) => {
if (request.sessionID === fixture.targetID && request.phase === "start")
requests.push({ type: "list", before: request.before })
},
beforeMessagesResponse: (request) => {
if (mode !== "candidate" || request.sessionID !== fixture.targetID || !request.before) return Promise.resolve()
historyGates++
return history!.promise
},
onMessage: (request) => {
if (request.sessionID === fixture.targetID && request.messageID === userID) requests.push({ type: "parent" })
},
message: (sessionID, messageID) => {
if (sessionID !== fixture.targetID || messageID !== userID) return
return user
},
pageMessages: (sessionID, limit, before) => {
const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
const end = before ? items.findIndex((message) => message.info.id === before) : items.length
const start = Math.max(0, end - limit)
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
},
})
await page.route(`**/session/${fixture.targetID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
)
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
const href = stressSessionHref(fixture.targetID)
await page.evaluate(
({ href, title }) => {
const link = document.createElement("a")
link.id = "parent-hydration-target"
link.href = href
link.textContent = title
document.body.append(link)
},
{ href, title: target.title },
)
const metrics = await measureSessionSwitch(page, {
destinationIDs: messages.map((message) => message.info.id),
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
lastID,
requiredPartID: lastPartID,
requireBottomAnchor: false,
href,
switch: async () => {
await page.locator("#parent-hydration-target").click()
await expectSessionTitle(page, target.title)
},
}).finally(() => history?.resolve())
expect(metrics.firstCorrectObservedMs).not.toBeNull()
const requestCounts = {
list: requests.filter((request) => request.type === "list").length,
parent: requests.filter((request) => request.type === "parent").length,
}
if (mode === "candidate") {
expect(requestCounts.parent).toBe(1)
expect(historyGates).toBe(1)
}
return { metrics, requestCounts, historyGateCount: historyGates }
}
@@ -4,6 +4,8 @@ export type SessionSwitchSample = {
source: string[]
hasVisibleRows: boolean
last: boolean
requiredPartVisible?: boolean
bottomAnchorRequired?: boolean
bottomErrorPx?: number
review?: {
fileHost: boolean
@@ -41,7 +43,8 @@ export function isCorrectDestination(sample: SessionSwitchSample) {
sample.destination.length > 0 &&
sample.source.length === 0 &&
sample.last &&
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1
sample.requiredPartVisible !== false &&
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
)
}
@@ -8,9 +8,16 @@ type SessionSwitchProbe = {
async function installSessionSwitchProbe(
page: Page,
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
input: {
destinationIDs: string[]
sourceIDs: string[]
lastID: string
requiredPartID?: string
requireBottomAnchor?: boolean
href: string
},
) {
await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => {
await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
const destination = new Set(destinationIDs)
const source = new Set(sourceIDs)
const samples: SessionSwitchSample[] = []
@@ -66,6 +73,13 @@ async function installSessionSwitchProbe(
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
const requiredPartVisible = requiredPartID
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
if (element.dataset.timelinePartId !== requiredPartID) return false
const rect = element.getBoundingClientRect()
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
})
: undefined
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
samples.push({
observedAtMs,
@@ -73,11 +87,22 @@ async function installSessionSwitchProbe(
source: visible.filter((id) => source.has(id)),
hasVisibleRows,
last: visible.includes(lastID),
requiredPartVisible,
bottomAnchorRequired: requireBottomAnchor !== false,
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
review,
})
} else {
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false, review })
samples.push({
observedAtMs,
destination: [],
source: [],
hasVisibleRows: false,
last: false,
requiredPartVisible: requiredPartID ? false : undefined,
bottomAnchorRequired: requireBottomAnchor !== false,
review,
})
}
requestAnimationFrame(sample)
}, 0)
@@ -117,7 +142,8 @@ async function waitForStableSessionSwitch(page: Page) {
sample.destination.length > 0 &&
sample.source.length === 0 &&
sample.last &&
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
sample.requiredPartVisible !== false &&
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1),
)
)
})
@@ -135,13 +161,27 @@ async function collectSessionSwitchResult(page: Page) {
export async function measureSessionSwitch(
page: Page,
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise<void> },
input: {
destinationIDs: string[]
sourceIDs: string[]
lastID: string
requiredPartID?: string
requireBottomAnchor?: boolean
href: string
switch: () => Promise<void>
},
) {
const { switch: run, ...probe } = input
await installSessionSwitchProbe(page, probe)
await run()
await waitForStableSessionSwitch(page)
return collectSessionSwitchResult(page)
try {
await run()
await waitForStableSessionSwitch(page)
return await collectSessionSwitchResult(page)
} finally {
await page.evaluate(() => {
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
})
}
}
export async function waitForStableTimeline(page: Page, lastID: string) {
@@ -0,0 +1,46 @@
import { expect, test } from "bun:test"
import type { Page, Route } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
test("applies message latency after a list response gate is released", async () => {
const events: string[] = []
const gate = Promise.withResolvers<void>()
let handler: ((route: Route) => Promise<void>) | undefined
const page = {
route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback
return Promise.resolve()
},
} as unknown as Page
await mockOpenCodeServer(page, {
provider: {},
directory: "C:/OpenCode",
project: {},
sessions: [{ id: "session" }],
messageDelay: 25,
beforeMessagesResponse: () => {
events.push("before")
return gate.promise
},
onMessages: (request) => events.push(request.phase),
pageMessages: () => {
events.push("page")
return { items: [] }
},
})
const response = handler!({
request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }),
fulfill: () => {
events.push("fulfill")
return Promise.resolve()
},
} as unknown as Route)
expect(events).toEqual(["start", "before"])
const released = performance.now()
gate.resolve()
await response
expect(performance.now() - released).toBeGreaterThanOrEqual(20)
expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
})
@@ -52,3 +52,35 @@ test("reports missing correctness without throwing", () => {
expect(result.firstCorrectObservedMs).toBeNull()
expect(result.stableObservedMs).toBeNull()
})
test("requires an explicitly tracked part to be visible", () => {
const result = classifySessionSwitch([
{
observedAtMs: 16,
destination: ["destination"],
source: [],
hasVisibleRows: true,
last: true,
requiredPartVisible: false,
bottomErrorPx: 0,
},
])
expect(result.firstCorrectObservedMs).toBeNull()
})
test("can measure content correctness without requiring a bottom anchor", () => {
const result = classifySessionSwitch([
{
observedAtMs: 16,
destination: ["destination"],
source: [],
hasVisibleRows: true,
last: true,
requiredPartVisible: true,
bottomAnchorRequired: false,
},
])
expect(result.firstCorrectObservedMs).toBe(16)
})
@@ -0,0 +1,55 @@
import { expect, test } from "bun:test"
import type { Page } from "@playwright/test"
import { measureSessionSwitch } from "../timeline/session-tab-switch-probe"
function testPage(waitFailure?: Error) {
const stops: unknown[] = []
const page = {
evaluate: async (_callback: unknown, input?: unknown) => {
if (input) return
stops.push(undefined)
},
waitForFunction: async () => {
if (waitFailure) throw waitFailure
},
} as unknown as Page
return { page, stops }
}
function input(run: () => Promise<void>) {
return {
destinationIDs: ["destination"],
sourceIDs: ["source"],
lastID: "destination",
href: "/session/destination",
switch: run,
}
}
test("stops sampling when the session switch fails", async () => {
const failure = new Error("switch failed")
const context = testPage()
await expect(
measureSessionSwitch(
context.page,
input(async () => Promise.reject(failure)),
),
).rejects.toBe(failure)
expect(context.stops).toHaveLength(1)
})
test("stops sampling when the stable wait fails", async () => {
const failure = new Error("stable wait failed")
const context = testPage(failure)
await expect(
measureSessionSwitch(
context.page,
input(async () => {}),
),
).rejects.toBe(failure)
expect(context.stops).toHaveLength(1)
})
@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const draftID = "draft_legacy_new_session"
const directory = "C:/OpenCode/LegacyNewSession"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("redirects a draft to the legacy new-session route", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_legacy_new_session",
worktree: directory,
vcs: "git",
name: "legacy-new-session",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
})
@@ -0,0 +1,83 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const draftID = "draft_new_session_panel_corner"
const directory = "C:/OpenCode/NewSessionPanelCorner"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({
viewport: { width: 935, height: 522 },
deviceScaleFactor: 1,
})
test("matches the rounded panel corners to the dark new-session background", async ({ page }, testInfo) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_new_session_panel_corner",
worktree: directory,
vcs: "git",
name: "new-session-panel-corner",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode-theme-id", "oc-2")
localStorage.setItem("opencode-color-scheme", "dark")
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
const panel = page.locator('main div[class*="rounded-[10px]"][class*="overflow-hidden"]')
await expect(panel).toHaveCount(1)
const box = await panel.boundingBox()
if (!box) throw new Error("New-session panel bounds are unavailable")
const screenshot = await page.screenshot({ path: testInfo.outputPath("new-session-dark.png") })
const corners = await page.evaluate(
async ({ source, points }) => {
const image = new Image()
image.src = source
await image.decode()
const canvas = document.createElement("canvas")
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const context = canvas.getContext("2d")
if (!context) throw new Error("2D canvas is unavailable")
context.drawImage(image, 0, 0)
return points.map((point) => Array.from(context.getImageData(point.x, point.y, 1, 1).data))
},
{
source: `data:image/png;base64,${screenshot.toString("base64")}`,
points: [
{ x: Math.floor(box.x), y: Math.floor(box.y) },
{ x: Math.ceil(box.x + box.width) - 1, y: Math.floor(box.y) },
{ x: Math.floor(box.x), y: Math.ceil(box.y + box.height) - 1 },
{ x: Math.ceil(box.x + box.width) - 1, y: Math.ceil(box.y + box.height) - 1 },
],
},
)
expect(corners.every(([red, green, blue, alpha]) => red <= 8 && green <= 8 && blue <= 8 && alpha === 255)).toBe(true)
})
@@ -0,0 +1,146 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewOpenFile"
const projectID = "proj_review_open_file"
const sessionID = "ses_review_open_file"
const title = "Review open file"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("opens and searches project files inline", async ({ page }) => {
const searches: { query: string; dirs?: string; limit?: number }[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "open-file-project",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: sessionID,
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [fileDiff("src/changed.ts")],
fileList: (path) => {
if (path) return []
return [
fileNode("README.md"),
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
]
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
findFiles: (input) => {
searches.push(input)
return input.query === "nested" ? ["src/nested.ts"] : []
},
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const panel = page.locator("#review-panel")
const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" })
await expect(filter).toBeFocused()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
await filter.fill("nested")
const result = panel.getByRole("option", { name: /nested\.ts/ })
await expect(result).toBeVisible()
const resultID = await result.getAttribute("id")
expect(resultID).toBeTruthy()
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await page.keyboard.press("Control+w")
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
})
function fileNode(path: string) {
return {
name: path,
path,
absolute: `${directory}/${path}`,
type: "file",
ignored: false,
}
}
function fileDiff(file: string) {
return {
file,
before: "before\n",
after: "after\n",
additions: 1,
deletions: 1,
status: "modified",
}
}
@@ -0,0 +1,152 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewStatePersistence"
const projectID = "proj_review_state_persistence"
const sessionA = "ses_review_state_a"
const sessionB = "ses_review_state_b"
const titleA = "Alpha review state"
const titleB = "Beta review state"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("restores review mode and selected file per session", async ({ page }) => {
await setup(page)
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
await selectMode(page, "Git changes", "Branch changes")
await selectFile(page, "beta.ts")
await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await selectFile(page, "gamma.ts")
await switchSession(page, titleA)
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts")
await selectMode(page, "Branch changes", "Git changes")
await expectSelectedFile(page, "alpha.ts")
await selectMode(page, "Git changes", "Branch changes")
await expectSelectedFile(page, "beta.ts")
await page.reload()
await expectSessionTitle(page, titleA)
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts")
await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await expectSelectedFile(page, "gamma.ts")
})
async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
await page.getByRole("option", { name: next }).click()
}
async function selectFile(page: Page, file: string) {
await page.getByRole("button", { name: file }).click()
await expectSelectedFile(page, file)
}
async function expectSelectedFile(page: Page, file: string) {
await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file)
}
async function switchSession(page: Page, title: string) {
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
await expectSessionTitle(page, title)
}
async function setup(page: Page) {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "review-state-persistence",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
pageMessages: () => ({ items: [] }),
})
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
}),
)
await page.route("**/vcs/diff**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
new URL(route.request().url()).searchParams.get("mode") === "branch"
? [diff("src/alpha.ts"), diff("src/beta.ts")]
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
),
}),
)
await page.addInitScript(
({ directory, server, sessions }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
},
{ directory, server, sessions: [sessionA, sessionB] },
)
}
function session(id: string, title: string, created: number) {
return {
id,
slug: id,
projectID,
directory,
title,
version: "dev",
time: { created, updated: created },
}
}
function diff(file: string) {
return {
file,
additions: 1,
deletions: 1,
status: "modified",
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
}
}
function sessionHref(sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
@@ -10,6 +10,9 @@ const sessionB = "ses_review_tab_b"
const titleA = "Alpha session"
const titleB = "Beta session"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const diffs = Array.from({ length: 2_740 }, (_, index) =>
fileDiff(`src/generated-${String(index).padStart(4, "0")}.ts`),
)
// Marks the review pane DOM node so a remount (fresh node) is detectable.
const PROBE = "original"
@@ -25,20 +28,34 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
const reviewTab = page.getByRole("tab", { name: /Review/ })
const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ })
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
const review = page.locator('#review-panel [data-component="session-review-v2"]')
await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: /example\.ts/ }))
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
await writeProbe(page)
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
expect(await readProbe(page)).toBe(PROBE)
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
expect(await readProbe(page)).toBe(PROBE)
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await viewport.hover()
await page.mouse.wheel(0, 100_000)
await expect
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible()
})
type Probed = HTMLElement & { __e2eProbe?: string }
@@ -80,16 +97,7 @@ async function setup(page: Page) {
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",
},
],
vcsDiff: diffs,
pageMessages: () => ({ items: [] }),
})
@@ -127,3 +135,13 @@ function session(id: string, title: string, created: number) {
function sessionHref(sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
function fileDiff(file: string) {
return {
file,
additions: 1,
deletions: 1,
status: "modified",
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
}
}
@@ -0,0 +1,263 @@
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewTerminalStacked"
const projectID = "proj_review_terminal_stacked"
const sessionID = "ses_review_terminal_stacked"
const title = "Review terminal stacked"
const branchDiffs = [
fileDiff(".github/actions/setup-bun/action.yml", 7),
...Array.from({ length: 2_739 }, (_, index) =>
fileDiff(
`src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`,
100,
false,
),
),
]
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
test.setTimeout(120_000)
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
let detailVersion = 1
let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "review-terminal-stacked",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: "review-terminal-stacked",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
sessionStatus: { [sessionID]: { type: "idle" } },
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
})
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
}),
)
await page.route("**/vcs/diff**", (route) => {
const url = new URL(route.request().url())
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
const detail = scope?.endsWith("/src/branch/d00027")
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
url.searchParams.get("mode") === "branch"
? detail
? branchDiffs
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
: branchDiffs
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
),
})
})
await page.route("**/pty", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
}),
)
await page.route("**/pty/pty_review_terminal", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.locator("#review-panel")).toBeVisible()
await expectTree(page, 8, "git-0.ts")
await selectMode(page, "Git changes", "Branch changes")
await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await treeViewport.hover()
await page.mouse.wheel(0, 100_000)
await expect
.poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
const lastFile = page.getByRole("button", { name: "generated-2738.ts" })
await expect(lastFile).toBeVisible()
const bottomGap = await lastFile.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
return viewport.bottom - element.getBoundingClientRect().bottom
})
expect(bottomGap).toBeGreaterThanOrEqual(0)
expect(bottomGap).toBeLessThanOrEqual(16)
const lazyDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
await lastFile.click()
await lazyDiff
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
await expect(preview).toContainText("after-1")
detailVersion = 2
events.push(statusEvent("busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
const refreshedDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
events.push(statusEvent("idle"))
await refreshedDiff
await expect(preview).toContainText("after-2")
await selectMode(page, "Branch changes", "Git changes")
await expectTree(page, 8, "git-0.ts")
await page.getByRole("button", { name: "git-0.ts" }).click()
await selectMode(page, "Git changes", "Branch changes")
await expectTree(page, 2_773, "action.yml")
const filter = page.getByRole("searchbox", { name: "Filter files" })
await filter.fill("generated-2738")
await expectTree(page, 1, "generated-2738.ts")
await filter.fill("")
await expectTree(page, 2_773, "action.yml")
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expectTree(page, 2_773, "action.yml")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toHaveCount(0)
await expectTree(page, 2_773, "action.yml")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml")
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.locator("#review-panel")).toHaveCount(0)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectTree(page, 2_773, "action.yml")
await page.setViewportSize({ width: 1_000, height: 700 })
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
await page.setViewportSize({ width: 1_000, height: 120 })
await page.setViewportSize({ width: 1_400, height: 900 })
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
})
async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
const option = page.getByRole("option", { name: next })
await expect(option).toBeVisible()
await option.click()
}
async function expectTree(page: Page, total: number, file: string) {
await expectMountedTree(page, total)
await expect(page.getByRole("button", { name: file })).toBeVisible()
}
async function expectMountedTree(page: Page, total: number) {
const tree = page.locator('#review-panel [data-component="file-tree-v2"]')
await expect(tree).toHaveAttribute("data-total-rows", String(total))
await expect
.poll(() => tree.evaluate((element) => element.querySelectorAll('[data-slot="file-tree-v2-row"]').length))
.toBeGreaterThan(0)
const state = await tree.evaluate((element) => ({
root: element.getBoundingClientRect().height,
viewport: element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect().height,
rows: element.querySelectorAll('[data-slot="file-tree-v2-row"]').length,
}))
expect(state.viewport).toBeGreaterThan(0)
expect(state.root).toBeGreaterThan(0)
expect(state.rows).toBeGreaterThan(0)
expect(state.rows).toBeLessThanOrEqual(60)
}
async function expectStackGeometry(page: Page) {
const geometry = await page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")!
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
const reviewParent = review.parentElement!.getBoundingClientRect()
const terminalParent = terminal.parentElement!.getBoundingClientRect()
return {
review: review.getBoundingClientRect().height,
reviewParent: reviewParent.height,
terminal: terminal.getBoundingClientRect().height,
terminalParent: terminalParent.height,
}
})
expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1)
expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1)
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
function statusEvent(type: "busy" | "idle") {
return {
directory,
payload: { type: "session.status", properties: { sessionID, status: { type } } },
}
}
function fileDiff(file: string, additions: number, loaded = true, version = 1) {
return {
file,
additions,
deletions: 0,
status: "modified",
patch: loaded
? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n`
: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`,
}
}
@@ -0,0 +1,220 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import {
assistantMessage,
directory,
messageUpdated,
project,
session,
sessionID,
status,
textPart,
title,
userID,
userMessage,
} from "../performance/timeline-stability/fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const assistants = Array.from({ length: 14 }, (_, index) =>
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
parentID: userID,
created: 1700000001000 + index * 1_000,
completed: index < 13,
}),
)
const messages = [userMessage(), ...assistants]
const lastAssistant = assistants.at(-1)!
const lastPartID = assistants.at(-1)!.parts[0]!.id
const userPartID = `prt_${userID}_text`
const completed = {
...lastAssistant.info,
time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
}
const scenarios = [
{ name: "completion", info: completed, idleFirst: false, interrupted: false },
{
name: "interruption",
info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } },
idleFirst: true,
interrupted: true,
},
] as const
test.use({ viewport: { width: 646, height: 1385 } })
for (const scenario of scenarios) {
test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => {
const requests: { before?: string; phase: "start" | "end" }[] = []
const pages: { before?: string; limit: number }[] = []
const roots: { sessionID: string; messageID: string }[] = []
const sequence: string[] = []
const history = Promise.withResolvers<void>()
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
retry: 20,
})
await mockOpenCodeServer(page, {
directory,
project: project(),
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()],
sessionStatus: { [sessionID]: { type: "busy" } },
beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()),
onMessages: (request) => {
requests.push(request)
sequence.push(`messages:${request.phase}:${request.before ?? "latest"}`)
},
onMessage: (request) => {
roots.push(request)
sequence.push(`message:${request.messageID}`)
},
message: (requestedSessionID, messageID) => {
if (requestedSessionID !== sessionID) return
return messages.find((item) => item.info.id === messageID)
},
pageMessages: (_, limit, before) => {
pages.push({ before, limit })
const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
const start = Math.max(0, end - limit)
return {
items: messages.slice(start, end),
cursor: start > 0 ? messages[start]!.info.id : undefined,
}
},
})
await page.addInitScript(
({ userPartID, lastPartID }) => {
const state = { armed: false, hidden: false, samples: 0, stop: false }
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
const sample = () => {
if (state.armed) {
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
const view = viewport?.getBoundingClientRect()
const visible = (partID: string) => {
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
const rect = part?.getBoundingClientRect()
return (
!!rect &&
!!view &&
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > view.top &&
rect.top < view.bottom
)
}
if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true
state.samples++
}
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
}
requestAnimationFrame(() => setTimeout(sample, 0))
},
{ userPartID, lastPartID },
)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection()
await expectSessionTitle(page, title)
await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2)
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
expect(sequence.slice(0, 4)).toEqual([
"messages:start:latest",
"messages:end:latest",
`message:${userID}`,
`messages:start:${messages.at(-2)!.info.id}`,
])
await page.evaluate(() => {
;(
window as Window & {
__historyRootProbe?: { armed: boolean }
}
).__historyRootProbe!.armed = true
})
await waitForProbeSamples(page, 0)
expect(await historyRootHidden(page)).toBe(false)
const beforeHistory = await probeSamples(page)
history.resolve()
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14)
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await waitForProbeSamples(page, beforeHistory)
expect(pages[0]).toEqual({ before: undefined, limit: 2 })
expect(roots).toEqual([{ sessionID, messageID: userID }])
const message = messageUpdated(scenario.info)
const idle = status("idle")
for (const event of scenario.idleFirst ? [idle, message] : [message, idle]) {
const beforeEvent = await probeSamples(page)
await transport.send(event)
if (event === idle) await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
if (event === message && scenario.interrupted)
await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
await waitForProbeSamples(page, beforeEvent)
const current = await timelineState(page)
expect(current, JSON.stringify(current)).toMatchObject({ virtual: true })
expect(current.rows, JSON.stringify(current)).toBeGreaterThan(0)
}
expect(requests[0]).toEqual({ before: undefined, phase: "start", sessionID })
expect(requests[1]).toEqual({ before: undefined, phase: "end", sessionID })
await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
await expect(page.locator('[data-timeline-row="bottom-spacer"]')).toBeVisible()
if (scenario.interrupted) await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
expect(
await page.evaluate(() => {
const state = (window as Window & { __historyRootProbe?: { hidden: boolean; stop: boolean } })
.__historyRootProbe!
state.stop = true
return state.hidden
}),
).toBe(false)
})
}
function timelineState(page: Page) {
return page.evaluate(() => ({
virtual: !!document.querySelector("[data-timeline-virtual-content]"),
rows: document.querySelectorAll("[data-timeline-key]").length,
}))
}
function probeSamples(page: Page) {
return page.evaluate(
() => (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples,
)
}
async function waitForProbeSamples(page: Page, after: number) {
await page.waitForFunction(
(after) =>
(window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples >= after + 3,
after,
)
}
function historyRootHidden(page: Page) {
return page.evaluate(
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
)
}
@@ -0,0 +1,228 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import {
assistantMessage,
setupTimeline,
shell,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
for (const deviceScaleFactor of [1.25, 1.5]) {
test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => {
const shellID = "prt_shell_outline"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
settings: { newLayoutDesigns: true, shellToolPartsExpanded: true },
reducedMotion: true,
deviceScaleFactor,
})
const part = page.locator(`[data-timeline-part-id="${shellID}"]`)
const output = part.locator('[data-component="bash-output"]')
const row = page.locator("[data-timeline-key]", { has: part })
await expect(output).toBeVisible()
await timeline.settle()
const geometry = await row.evaluate((element) => {
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')
if (!output) throw new Error("Shell output is unavailable")
const rowRect = element.getBoundingClientRect()
const outputRect = output.getBoundingClientRect()
// Match a rounded-down measurement at a fractional device-pixel phase.
element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px`
element.style.transform = "translateY(0.25px)"
output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)")
output.style.setProperty("background", "rgb(0, 0, 0)", "important")
const style = getComputedStyle(output)
return {
outputWidth: outputRect.width,
outputHeight: outputRect.height,
borderColor: style.borderTopColor,
boxShadow: style.boxShadow,
clipMargin: getComputedStyle(element).overflowClipMargin,
}
})
await timeline.settle()
const clipped = await row.evaluate((element) => {
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')!
return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom
})
expect(clipped).toBeCloseTo(0.49, 1)
expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor)
const edges = await captureCardEdges(page, output)
expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
expect(geometry.boxShadow).toBe("none")
expect(geometry.clipMargin).toBe("0.5px")
expect(edges.magenta.top).toBeGreaterThan(0.75)
expect(edges.magenta.bottom).toBeGreaterThan(0.75)
expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
})
}
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
const patchID = "prt_patch_outline"
const file = {
filePath: "src/outline.ts",
relativePath: "src/outline.ts",
type: "update",
additions: 1,
deletions: 1,
before: "const outline = false\n",
after: "const outline = true\n",
}
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
]),
],
settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
reducedMotion: true,
})
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
const row = page.locator("[data-timeline-key]", { has: part })
await expect(card).toBeVisible()
await timeline.settle()
const geometry = await row.evaluate((element) => {
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
if (!card) throw new Error("Patch card is unavailable")
const rowRect = element.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px`
const clipMargin = getComputedStyle(element).overflowClipMargin
const bottom = element.getBoundingClientRect().bottom
return {
overflow: card.getBoundingClientRect().bottom - bottom,
paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
clipMargin,
cardWidth: cardRect.width,
cardHeight: cardRect.height,
}
})
await timeline.settle()
expect(geometry.overflow).toBeCloseTo(0.49, 1)
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
const edges = await captureCardEdges(page, card)
expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
expect(edges.luminance.top).toBeLessThan(245)
expect(edges.luminance.bottom).toBeLessThan(245)
expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
expect(geometry.clipMargin).toBe("0.5px")
})
test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => {
const secondUserID = "msg_outline_second_user"
await setupTimeline(page, {
messages: [
userMessage(undefined, {
summary: {
diffs: [
{
file: "src/summary.ts",
additions: 1,
deletions: 1,
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
},
],
},
}),
assistantMessage([textPart("prt_outline_text", "Assistant text")]),
userMessage(undefined, { id: secondUserID, created: 1700000010000 }),
assistantMessage([], {
id: "msg_outline_second_assistant",
parentID: secondUserID,
created: 1700000011000,
}),
],
})
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
elements.map((element) => ({
tag: element.querySelector<HTMLElement>("[data-timeline-row]")?.dataset.timelineRow,
clipMargin: getComputedStyle(element).overflowClipMargin,
})),
)
expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true)
expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }])
})
async function captureCardEdges(page: Page, card: Locator) {
const box = await card.boundingBox()
if (!box) throw new Error("Tool card bounds are unavailable")
const viewport = page.viewportSize()
if (!viewport) throw new Error("Viewport bounds are unavailable")
const screenshot = await page.screenshot()
return page.evaluate(
async ({ source, box, viewport }) => {
const image = new Image()
image.src = source
await image.decode()
const canvas = document.createElement("canvas")
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const context = canvas.getContext("2d")
if (!context) throw new Error("2D canvas is unavailable")
context.drawImage(image, 0, 0)
const scale = {
x: image.naturalWidth / viewport.width,
y: image.naturalHeight / viewport.height,
}
const rows = (candidates: number[]) => {
const left = Math.floor((box.x + 8) * scale.x)
const width = Math.floor((box.width - 16) * scale.x)
return candidates.map((row) => {
const pixels = context.getImageData(left, row, width, 1).data
const indexes = Array.from({ length: width }, (_, index) => index * 4)
return {
luminance:
indexes
.map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
.reduce((sum, value) => sum + value, 0) / width,
magenta:
indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
.length / width,
}
})
}
const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
const columns = new Uint32Array(image.naturalWidth)
for (let index = 0; index < pixels.length; index += 4) {
if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
}
const top = box.y * scale.y
const bottom = (box.y + box.height) * scale.y
const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
return {
box,
luminance: {
top: Math.min(...topRows.map((row) => row.luminance)),
bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
},
magenta: {
top: Math.max(...topRows.map((row) => row.magenta)),
bottom: Math.max(...bottomRows.map((row) => row.magenta)),
vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
},
}
},
{
source: `data:image/png;base64,${screenshot.toString("base64")}`,
viewport,
box,
},
)
}
@@ -0,0 +1,89 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/TerminalComposerFocus"
const projectID = "proj_terminal_composer_focus"
const sessionID = "ses_terminal_composer_focus"
const ptyID = "pty_terminal_composer_focus"
test.use({ viewport: { width: 1440, height: 900 } })
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "terminal-composer-focus",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: "terminal-composer-focus",
projectID,
directory,
title: "Terminal composer focus",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.route("**/pty", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: 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" }),
}),
)
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
await page.keyboard.type("x")
await expect(composer).toHaveText("")
await page.waitForTimeout(300)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await page.keyboard.type("a")
await expect(composer).toBeFocused()
await expect(composer).toHaveText("a")
})
@@ -7,7 +7,7 @@ const projectID = "proj_hidden_terminal_regression"
const sessionID = "ses_hidden_terminal_regression"
const title = "Hidden terminal regression"
test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => {
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
directory,
@@ -64,13 +64,14 @@ test("unmounts the terminal renderer while the pane is hidden", async ({ page })
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
await page.keyboard.press("Control+Backquote")
await expect(panel).toHaveAttribute("aria-hidden", "true")
await expect(panel).toHaveCount(0)
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
await page.setViewportSize({ width: 1200, height: 700 })
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
await page.keyboard.press("Control+Backquote")
await expect(panel).toBeVisible()
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
})
+1
View File
@@ -10,6 +10,7 @@
"./performance/timeline-stability/fixture.test.ts",
"./performance/timeline-stability/fixture.ts",
"./performance/unit/visual-stability.test.ts",
"./regression/new-session-panel-corner.spec.ts",
"./regression/session-timeline-context-resize.spec.ts",
"./utils/**/*.ts"
]
+37 -2
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"])
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
export interface MockServerConfig {
provider: unknown
@@ -11,7 +11,10 @@ export interface MockServerConfig {
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
vcsDiff?: unknown[]
messageDelay?: number
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
message?: (sessionID: string, messageID: string) => unknown
onMessage?: (input: { sessionID: string; messageID: string }) => void
events?: () => unknown[]
eventRetry?: number
todos?: (sessionID: string) => unknown[]
@@ -19,6 +22,7 @@ export interface MockServerConfig {
questions?: unknown[] | (() => unknown[])
fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: unknown
}
@@ -52,6 +56,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
if (path === "/global/health") return json(route, { healthy: true })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
if (path === "/question")
@@ -62,6 +67,23 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
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 (path === "/find/file" && config.findFiles)
return json(
route,
await config.findFiles({
query: url.searchParams.get("query") ?? "",
dirs: url.searchParams.get("dirs") ?? undefined,
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
}),
)
if (path === "/api/reference")
return json(route, {
location: {
directory: config.directory,
project: { id: (config.project as { id?: string }).id, directory: config.directory },
},
data: [],
})
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path in staticRoutes) return json(route, staticRoutes[path])
@@ -72,6 +94,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
return json(route, session ?? {})
}
const projectMatch = path.match(/^\/project\/([^/]+)$/)
if (projectMatch) return json(route, config.project)
const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
if (messageMatch) {
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const message = config.message?.(messageMatch[1]!, messageMatch[2]!)
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
return json(route, message)
}
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
@@ -82,7 +116,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const limit = Number(url.searchParams.get("limit") ?? 80)
const pageData = config.pageMessages(messagesMatch[1], limit, before)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.13",
"version": "1.17.15",
"description": "",
"type": "module",
"exports": {
+124 -51
View File
@@ -9,9 +9,10 @@ import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import { base64Encode } from "@opencode-ai/core/util/encode"
import {
type Component,
createEffect,
@@ -32,7 +33,7 @@ import { CommandProvider, useCommand, type CommandOption } from "@/context/comma
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
@@ -52,9 +53,10 @@ import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { createSessionLineage } from "@/pages/session/session-lineage"
import { SessionPage, TargetSessionRouteContent } from "@/pages/session"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home"
const NewSession = lazy(() => import("@/pages/new-session"))
@@ -88,10 +90,14 @@ const SessionRoute = () => {
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
})
return <SessionPage />
return (
<SessionRouteErrorBoundary sessionID={params.id}>
<SessionPage />
</SessionRouteErrorBoundary>
)
}
const TargetSessionRoute = () => {
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string; id: string }>()
const global = useGlobal()
const conn = createMemo(() => {
@@ -105,14 +111,47 @@ const TargetSessionRoute = () => {
// re-resolves reactively instead); both rely on this key for server changes.
<Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<TargetSessionRouteContent />
</ServerSyncProvider>
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
}
const TargetSessionRoute = () => (
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
)
function LegacyTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
return (
<TargetServerRoute>
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
<LegacyTargetSessionRedirect />
</SessionRouteErrorBoundary>
</TargetServerRoute>
)
}
function LegacyTargetSessionRedirect() {
const params = useParams<{ id: string }>()
const navigate = useNavigate()
const sync = useServerSync()
const current = createSessionLineage(
() => params.id,
() => sync().session.lineage,
)
createEffect(() => {
const directory = current()?.session.directory
if (!directory) return
navigate(legacySessionHref(directory, params.id), { replace: true })
})
return null
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server.
@@ -126,16 +165,17 @@ function SelectedServerProviders(props: ParentProps) {
)
}
function LegacyServerLayout(props: ParentProps) {
function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
return (
<SelectedServerProviders>
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
<LegacyServerScopedShell serverScoped={props.serverScoped}>{props.children}</LegacyServerScopedShell>
</SelectedServerProviders>
)
}
function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const settings = useSettings()
const tabs = useTabs()
return (
<Show when={tabs.ready()}>
@@ -144,7 +184,14 @@ function DraftRoute() {
keyed
fallback={<Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
{(draft) => (
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
>
<ResolvedDraftRoute draft={draft} />
</Show>
)}
</Show>
</Show>
)
@@ -186,7 +233,7 @@ declare global {
deepLinks?: string[]
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise<void>
exportDebugLogs?: () => Promise<string>
}
}
@@ -263,12 +310,14 @@ function DesktopCommands() {
type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
sessionID?: () => string | undefined
serverScoped?: JSX.Element
}>
function ServerScopedProviders(props: ServerScopedShellProps) {
return (
<PermissionProvider directory={props.directory}>
<LayoutProvider>
{props.serverScoped}
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</LayoutProvider>
</PermissionProvider>
@@ -277,16 +326,16 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
function LegacyServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}>
<LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders>
)
}
function NewAppLayout(props: ParentProps) {
function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
return (
<SelectedServerProviders>
<ServerScopedProviders>
<ServerScopedProviders serverScoped={props.serverScoped}>
<NewLayout>{props.children}</NewLayout>
</ServerScopedProviders>
</SelectedServerProviders>
@@ -318,8 +367,8 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
<MetaProvider>
<Font />
<ThemeProvider
onThemeApplied={(_, mode) => {
void window.api?.setTitlebar?.({ mode })
onThemeApplied={(_, mode, scheme) => {
void window.api?.setTitlebar?.({ mode, scheme })
}}
>
<LanguageProvider locale={props.locale}>
@@ -347,7 +396,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
)
}
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise<void> }>) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
@@ -376,34 +425,45 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
const checking = createMemo(
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
)
const [startup] = createResource(async () => {
if (!props.startup) return true
await props.startup.catch((error) => {
console.error("[startup] startup gate failed", error)
})
return true
})
const startupChecking = createMemo(
() => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state),
)
const loading = createMemo(() => checking() || startupChecking())
return (
<Show
when={!checking()}
fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<>
<Show when={!checking()}>
<Show
when={startupHealthCheck.latest}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") void healthCheckActions.refetch()
}}
onServerSelected={(key) => {
setCheckMode("blocking")
server.setActive(key)
void healthCheckActions.refetch()
}}
/>
}
>
{props.children}
</Show>
</Show>
<Show when={loading()}>
<div class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>
<Show
when={startupHealthCheck.latest}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") void healthCheckActions.refetch()
}}
onServerSelected={(key) => {
setCheckMode("blocking")
server.setActive(key)
void healthCheckActions.refetch()
}}
/>
}
>
{props.children}
</Show>
</Show>
</>
)
}
@@ -470,6 +530,8 @@ export function AppInterface(props: {
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
startup?: Promise<void>
serverScoped?: JSX.Element
}) {
// The visual new layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
@@ -491,7 +553,7 @@ export function AppInterface(props: {
>
<GlobalProvider>
<SettingsProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
<Dynamic
component={props.router ?? Router}
@@ -500,14 +562,14 @@ export function AppInterface(props: {
<NotificationProvider>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout>{routerProps.children}</NewAppLayout>
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</NotificationProvider>
</TabsProvider>
)}
>
<Routes />
<Routes serverScoped={props.serverScoped} />
</Dynamic>
</Show>
</ConnectionGate>
@@ -517,13 +579,24 @@ export function AppInterface(props: {
)
}
function Routes() {
function Routes(props: { serverScoped?: JSX.Element }) {
const settings = useSettings()
return (
<>
<Route component={LegacyServerLayout}>
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
<Route
component={(routeProps) => (
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
)}
>
<Show when={!settings.general.newLayoutDesigns()}>
{
<>
<Route path="/" component={LegacyHome} />
<Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
</>
}
</Show>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
@@ -531,15 +604,15 @@ function Routes() {
</Route>
<Show when={settings.general.newLayoutDesigns()}>
<Route path="/" component={NewHome} />
<Route path="/:dir/session/:id" component={LegacyTargetSessionRoute} />
<Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</Show>
<Route path="/new-session" component={DraftRoute} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</>
)
}
function LegacyTargetSessionRoute() {
function NewLayoutLegacySessionRedirect() {
const server = useServer()
const tabs = useTabs()
const params = useParams<{ id: string }>()
@@ -0,0 +1,325 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useNavigate } from "@solidjs/router"
import { createMemo, onCleanup } from "solid-js"
import { useCommand, type CommandOption } from "@/context/command"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
export type CommandPaletteEntry = {
id: string
type: "command" | "file" | "session"
title: string
description?: string
keybind?: string
category: string
option?: CommandOption
path?: string
directory?: string
sessionID?: string
archived?: number
updated?: number
}
const ENTRY_LIMIT = 5
const COMMON_COMMAND_IDS = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
] as const
export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) {
const seen = new Set<string>()
return items.filter((item) => {
if (seen.has(item.id)) return false
seen.add(item.id)
return true
})
}
export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry {
return {
id: "file:" + path,
type: "file",
title: path,
category,
path,
}
}
export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) {
const file = useFile()
const layout = useLayout()
const { tabs, view } = useSessionLayout()
return (path: string) => {
const value = file.tab(path)
void tabs().open(value)
void file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all")
onOpenFile?.(path)
tabs().setActive(value)
}
}
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const language = useLanguage()
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()()
const serverSync = useServerSync()
const { params, tabs } = useSessionLayout()
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const filesOnly = () => props.filesOnly?.() ?? false
const allowedCommands = createMemo(() => {
if (filesOnly()) return []
return command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
return allowedCommands().map((option) => createCommandEntry(option, category))
})
const preferredCommandEntries = createMemo(() => {
const all = allowedCommands()
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, category))
})
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
const recentFileEntries = createMemo(() => {
const all = tabState.openedTabs()
const active = tabState.activeFileTab()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const category = language.t("palette.group.files")
return order
.map((item) => file.pathFromTab(item))
.filter((path): path is string => {
if (!path || seen.has(path)) return false
seen.add(path)
return true
})
.slice(0, ENTRY_LIMIT)
.map((path) => createCommandPaletteFileEntry(path, category))
})
const rootFileEntries = createMemo(() => {
const category = language.t("palette.group.files")
return file.tree
.children("")
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
.slice(0, ENTRY_LIMIT)
.map((path) => createCommandPaletteFileEntry(path, category))
})
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return undefined
return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync().data.path.home)
const sessions = createSessionEntries({
workspaces,
label: (directory) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
},
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
const highlight = (item: CommandPaletteEntry | undefined) => {
state.cleanup?.()
state.cleanup = undefined
if (item?.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const select = (item: CommandPaletteEntry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") {
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
return
}
if (!item.path) return
openFile(item.path)
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return {
language,
file,
commandEntries,
preferredCommandEntries,
recentFileEntries,
rootFileEntries,
sessions,
highlight,
select,
close: () => dialog.close(),
}
}
function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
return {
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category,
option,
}
}
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
untitled: () => string
category: () => string
}) {
const state: {
token: number
inflight: Promise<CommandPaletteEntry[]> | undefined
cached: CommandPaletteEntry[] | undefined
} = { token: 0, inflight: undefined, cached: undefined }
return (text: string) => {
if (!text.trim()) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as CommandPaletteEntry[]
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as CommandPaletteEntry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props
.load(directory)
.then((result) =>
(result.data ?? [])
.filter((session) => !!session?.id)
.map((session) => ({
id: session.id,
title: session.title ?? props.untitled(),
description,
directory,
archived: session.time?.archived,
updated: session.time?.updated,
})),
)
.catch(() => [] as SessionEntryInput[])
}),
)
.then((results) => {
if (state.token !== current) return [] as CommandPaletteEntry[]
const seen = new Set<string>()
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, props.category()))
state.cached = next
return next
})
.catch(() => [] as CommandPaletteEntry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
}
type SessionEntryInput = {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
}
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
return {
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
}
}
@@ -0,0 +1,220 @@
.command-palette-v2 {
overflow: hidden;
}
/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the
top stays put while the content-driven height grows and shrinks. */
[data-component="dialog-v2"]:has(.command-palette-v2) {
align-items: flex-start;
}
[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] {
width: min(calc(100vw - 24px), 640px);
height: auto;
min-height: 280px;
max-height: min(calc(100vh - 96px), 480px);
margin-top: max(48px, calc((100vh - 480px) / 2));
border-radius: 12px;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-floating);
}
.command-palette-v2-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 0;
padding: 0;
}
.command-palette-v2-search {
flex-shrink: 0;
padding: 6px;
}
.command-palette-v2-search [data-component="text-input-v2"] {
width: 100%;
height: 36px;
border-radius: 6px;
outline: 0;
background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent);
box-shadow: none;
transition:
background-color 120ms ease-in-out,
box-shadow 120ms ease-in-out;
}
.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
background: var(--v2-background-bg-layer-02);
box-shadow: none;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
border-radius: 6px;
background: transparent;
gap: 8px;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
padding-left: 12px;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
border-radius: 6px;
background: transparent;
}
.command-palette-v2-scroll {
min-height: 0;
flex: 1;
}
.command-palette-v2-results {
display: flex;
flex-direction: column;
gap: 16px;
padding: 6px 6px 8px;
}
.command-palette-v2-group {
display: flex;
flex-direction: column;
gap: 1px;
}
.command-palette-v2-group-title {
margin: 6px 0;
padding: 0 12px;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
user-select: none;
}
.command-palette-v2-row {
display: flex;
width: 100%;
height: 36px;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 12px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--v2-text-text-base);
text-align: left;
cursor: default;
scroll-margin: 6px 0;
}
.command-palette-v2-row[data-active] {
background: var(--v2-overlay-simple-overlay-hover);
}
.command-palette-v2-row:focus-visible {
background: var(--v2-overlay-simple-overlay-hover);
outline: none;
}
.command-palette-v2-row-main {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 8px;
}
.command-palette-v2-row-icon {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.command-palette-v2-row-text {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.command-palette-v2-title {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: 16px;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-description,
.command-palette-v2-meta {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-meta {
flex-shrink: 0;
}
.command-palette-v2-file-path {
display: flex;
min-width: 0;
align-items: baseline;
font-size: 13px;
line-height: 16px;
letter-spacing: -0.04px;
}
.command-palette-v2-file-dir {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
font-weight: 440;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-file-name {
flex-shrink: 0;
color: var(--v2-text-text-base);
font-weight: 530;
white-space: nowrap;
}
.command-palette-v2-state {
display: grid;
min-height: 120px;
place-items: center;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
}
@media (max-width: 640px) {
.command-palette-v2-row-text {
flex-direction: column;
align-items: flex-start;
gap: 1px;
}
.command-palette-v2-description {
max-width: 100%;
}
}
@@ -0,0 +1,221 @@
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { formatKeybindParts } from "@/context/command"
import { useLanguage } from "@/context/language"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteFileEntry,
createCommandPaletteModel,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
import "./dialog-command-palette-v2.css"
function groups(entries: CommandPaletteEntry[]) {
const map = new Map<string, CommandPaletteEntry[]>()
for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry])
return Array.from(map.entries()).map(([category, entries]) => ({ category, entries }))
}
function matchesEntry(entry: CommandPaletteEntry, query: string) {
const value = query.toLowerCase()
return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value))
}
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const loadItems = async (text: string) => {
const q = text.trim()
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
const category = palette.language.t("palette.group.files")
return [
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
...nextSessions.filter((entry) => matchesEntry(entry, q)),
...files.map((path) => createCommandPaletteFileEntry(path, category)),
]
}
const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
const groupedEntries = createMemo(() => groups(visibleEntries()))
const activeEntry = createMemo(() => visibleEntries()[active()])
createEffect(() => {
query()
visibleEntries()
setActive(0)
})
createEffect(() => {
palette.highlight(activeEntry())
})
let resultsRef: HTMLDivElement | undefined
const move = (delta: -1 | 1) => {
const count = visibleEntries().length
if (count === 0) return
setActive((index) => (index + delta + count) % count)
requestAnimationFrame(() => {
resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" })
})
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowDown") {
event.preventDefault()
move(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
move(-1)
return
}
if (event.key === "Enter") {
event.preventDefault()
palette.select(activeEntry())
return
}
if (event.key === "Escape") {
event.preventDefault()
palette.close()
}
}
return (
<Dialog class="command-palette-v2" size="large">
<DialogBody class="command-palette-v2-body">
<div class="command-palette-v2-search">
<TextInputV2
value={query()}
autofocus
autocomplete="off"
spellcheck={false}
appearance="large"
placeholder={palette.language.t("palette.search.placeholder")}
leadingIcon={<Icon name="magnifying-glass" />}
onInput={(event) => setQuery(event.currentTarget.value)}
onKeyDown={handleKeyDown}
/>
</div>
<ScrollView class="command-palette-v2-scroll" viewportRef={(el) => (resultsRef = el)}>
<div class="command-palette-v2-results" role="listbox">
<Show
when={visibleEntries().length > 0}
fallback={
<div class="command-palette-v2-state">
{entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
</div>
}
>
<For each={groupedEntries()}>
{(group) => (
<div class="command-palette-v2-group">
<Show when={group.category}>
<div class="command-palette-v2-group-title">{group.category}</div>
</Show>
<For each={group.entries}>
{(item) => (
<PaletteRow
item={item}
active={activeEntry()?.id === item.id}
language={palette.language}
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
onSelect={() => palette.select(item)}
/>
)}
</For>
</div>
)}
</For>
</Show>
</div>
</ScrollView>
</DialogBody>
</Dialog>
)
}
function PaletteRow(props: {
item: CommandPaletteEntry
active: boolean
language: ReturnType<typeof useLanguage>
onActive: () => void
onSelect: () => void
}) {
return (
<button
type="button"
class="command-palette-v2-row"
role="option"
aria-selected={props.active}
data-active={props.active ? "" : undefined}
onMouseMove={(event) => {
// Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
if (event.movementX === 0 && event.movementY === 0) return
props.onActive()
}}
onMouseDown={(event) => event.preventDefault()}
onClick={props.onSelect}
>
<Switch
fallback={
<div class="command-palette-v2-row-main">
<FileIcon node={{ path: props.item.path ?? "", type: "file" }} class="command-palette-v2-row-icon size-4" />
<div class="command-palette-v2-file-path">
<span class="command-palette-v2-file-dir">{getDirectory(props.item.path ?? "")}</span>
<span class="command-palette-v2-file-name">{getFilename(props.item.path ?? "")}</span>
</div>
</div>
}
>
<Match when={props.item.type === "command"}>
<div class="command-palette-v2-row-main">
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title">{props.item.title}</span>
<Show when={props.item.description}>
<span class="command-palette-v2-description">{props.item.description}</span>
</Show>
</div>
</div>
<Show when={props.item.keybind}>
<KeybindV2 keys={formatKeybindParts(props.item.keybind ?? "", props.language.t)} variant="neutral" />
</Show>
</Match>
<Match when={props.item.type === "session"}>
<div class="command-palette-v2-row-main">
<Icon name="status" class="command-palette-v2-row-icon" />
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.title}
</span>
<Show when={props.item.description}>
<span class="command-palette-v2-description" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.description}
</span>
</Show>
</div>
</div>
<Show when={props.item.updated}>
<span class="command-palette-v2-meta">
{getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)}
</span>
</Show>
</Match>
</Switch>
</button>
)
}
@@ -7,29 +7,174 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { List, type ListRef } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tag } from "@opencode-ai/ui/tag"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast"
import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import {
type Accessor,
type Component,
createEffect,
createMemo,
createResource,
Match,
onCleanup,
onMount,
Show,
Switch,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider"
export function DialogConnectProvider(props: { provider: string; directory?: Accessor<string | undefined> }) {
const CUSTOM_ID = "_custom"
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const reset = () => setStore("selected", undefined)
return {
selected: () => store.selected,
select: (provider?: string) => setStore("selected", provider),
back: options.onBack ?? reset,
}
}
export const DialogConnectProvider: Component<{
directory?: Accessor<string | undefined>
controller?: ReturnType<typeof useProviderConnectController>
}> = (props) => {
const fallback = useProviderConnectController()
const controller = props.controller ?? fallback
const language = useLanguage()
const reset = controller.back
const back = { current: reset }
const select = (provider?: string) => {
back.current = reset
controller.select(provider)
}
return (
<Dialog
class="h-full"
transition
title={
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
/>
</Show>
}
>
<Switch>
<Match when={controller.selected() === CUSTOM_ID}>
<CustomProviderForm />
</Match>
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
{(provider) => (
<ProviderConnection
provider={provider()}
directory={props.directory}
onBack={reset}
setBack={(handler) => (back.current = handler)}
/>
)}
</Match>
<Match when={true}>
<ProviderPicker directory={props.directory} onSelect={select} />
</Match>
</Switch>
</Dialog>
)
}
function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
const providers = useProviders(props.directory)
const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other")
const customLabel = () => language.t("settings.providers.tag.custom")
const note = (id: string) => {
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
if (id === "openai") return language.t("dialog.provider.openai.note")
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
return undefined
}
return (
<List
class="px-3"
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.provider.empty")}
activeIcon="plus-small"
key={(x) => x?.id}
items={() => {
language.locale()
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
}}
filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
sortBy={(a, b) => {
if (a.id === CUSTOM_ID) return -1
if (b.id === CUSTOM_ID) return 1
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
}}
sortGroupsBy={(a, b) => {
const popular = popularGroup()
if (a.category === popular && b.category !== popular) return -1
if (b.category === popular && a.category !== popular) return 1
return 0
}}
onSelect={(x) => {
if (!x) return
props.onSelect(x.id)
}}
>
{(i) => (
<div class="px-1.25 w-full flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
</Show>
<Show when={i.id === CUSTOM_ID}>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</Show>
<Show when={i.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
<Show when={i.id === "opencode-go"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
</div>
)}
</List>
)
}
function ProviderConnection(props: {
provider: string
directory?: Accessor<string | undefined>
onBack: () => void
setBack: (handler: () => void) => void
}) {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const language = useLanguage()
const providers = useProviders(props.directory)
const all = () => {
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider directory={props.directory} />)
})
}
const alive = { value: true }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -364,21 +509,15 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
}
function goBack() {
if (methods().length === 1) {
all()
return
}
if (store.authorization) {
if (methods().length > 1 && store.methodIndex !== undefined) {
dispatch({ type: "method.reset" })
return
}
if (store.methodIndex !== undefined) {
dispatch({ type: "method.reset" })
return
}
all()
props.onBack()
}
props.setBack(goBack)
function MethodSelection() {
return (
<>
@@ -599,79 +738,67 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
}
return (
<Dialog
title={
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={goBack}
aria-label={language.t("common.goBack")}
/>
}
>
<div class="flex flex-col gap-6 px-2.5 pb-3">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">
<Switch>
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
{language.t("provider.connect.title.anthropicProMax")}
</Match>
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
</Switch>
</div>
</div>
<div class="px-2.5 pb-10 flex flex-col gap-6">
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
<Switch>
<Match when={loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={store.methodIndex === undefined}>
<MethodSelection />
</Match>
<Match when={store.state === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={store.state === "prompt"}>
<AuthPromptsView />
</Match>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
</div>
</div>
</Match>
<Match when={method()?.type === "api"}>
<ApiAuthView />
</Match>
<Match when={method()?.type === "oauth"}>
<Switch>
<Match when={store.authorization?.method === "code"}>
<OAuthCodeView />
</Match>
<Match when={store.authorization?.method === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
</Match>
</Switch>
</div>
<div class="flex flex-col gap-6 px-2.5 pb-3">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">
<Switch>
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
{language.t("provider.connect.title.anthropicProMax")}
</Match>
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
</Switch>
</div>
</div>
</Dialog>
<div class="px-2.5 pb-10 flex flex-col gap-6">
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
<Switch>
<Match when={loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={store.methodIndex === undefined}>
<MethodSelection />
</Match>
<Match when={store.state === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={store.state === "prompt"}>
<AuthPromptsView />
</Match>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
</div>
</div>
</Match>
<Match when={method()?.type === "api"}>
<ApiAuthView />
</Match>
<Match when={method()?.type === "oauth"}>
<Switch>
<Match when={store.authorization?.method === "code"}>
<OAuthCodeView />
</Match>
<Match when={store.authorization?.method === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
</Match>
</Switch>
</div>
</div>
</div>
)
}
@@ -6,21 +6,41 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast"
import { type Accessor, batch, For } from "solid-js"
import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
import { DialogSelectProvider } from "./dialog-select-provider"
type Props = {
back?: "providers" | "close"
directory?: Accessor<string | undefined>
onBack: () => void
}
export function DialogCustomProvider(props: Props) {
const language = useLanguage()
return (
<Dialog
class="h-full"
title={
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={props.onBack}
aria-label={language.t("common.goBack")}
/>
}
transition
>
<CustomProviderForm />
</Dialog>
)
}
export function CustomProviderForm() {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
@@ -36,14 +56,6 @@ export function DialogCustomProvider(props: Props) {
err: {},
})
const goBack = () => {
if (props.back === "close") {
dialog.close()
return
}
dialog.show(() => <DialogSelectProvider directory={props.directory} />)
}
const addModel = () => {
setForm(
"models",
@@ -163,168 +175,155 @@ export function DialogCustomProvider(props: Props) {
}
return (
<Dialog
title={
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={goBack}
aria-label={language.t("common.goBack")}
/>
}
transition
>
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
</div>
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base">
{language.t("provider.custom.description.prefix")}
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
{language.t("provider.custom.description.link")}
</Link>
{language.t("provider.custom.description.suffix")}
</p>
<div class="flex flex-col gap-4">
<TextField
autofocus
label={language.t("provider.custom.field.providerID.label")}
placeholder={language.t("provider.custom.field.providerID.placeholder")}
description={language.t("provider.custom.field.providerID.description")}
value={form.providerID}
onChange={(v) => setField("providerID", v)}
validationState={form.err.providerID ? "invalid" : undefined}
error={form.err.providerID}
/>
<TextField
label={language.t("provider.custom.field.name.label")}
placeholder={language.t("provider.custom.field.name.placeholder")}
value={form.name}
onChange={(v) => setField("name", v)}
validationState={form.err.name ? "invalid" : undefined}
error={form.err.name}
/>
<TextField
label={language.t("provider.custom.field.baseURL.label")}
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
value={form.baseURL}
onChange={(v) => setField("baseURL", v)}
validationState={form.err.baseURL ? "invalid" : undefined}
error={form.err.baseURL}
/>
<TextField
label={language.t("provider.custom.field.apiKey.label")}
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
description={language.t("provider.custom.field.apiKey.description")}
value={form.apiKey}
onChange={(v) => setField("apiKey", v)}
/>
</div>
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base">
{language.t("provider.custom.description.prefix")}
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
{language.t("provider.custom.description.link")}
</Link>
{language.t("provider.custom.description.suffix")}
</p>
<div class="flex flex-col gap-4">
<TextField
autofocus
label={language.t("provider.custom.field.providerID.label")}
placeholder={language.t("provider.custom.field.providerID.placeholder")}
description={language.t("provider.custom.field.providerID.description")}
value={form.providerID}
onChange={(v) => setField("providerID", v)}
validationState={form.err.providerID ? "invalid" : undefined}
error={form.err.providerID}
/>
<TextField
label={language.t("provider.custom.field.name.label")}
placeholder={language.t("provider.custom.field.name.placeholder")}
value={form.name}
onChange={(v) => setField("name", v)}
validationState={form.err.name ? "invalid" : undefined}
error={form.err.name}
/>
<TextField
label={language.t("provider.custom.field.baseURL.label")}
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
value={form.baseURL}
onChange={(v) => setField("baseURL", v)}
validationState={form.err.baseURL ? "invalid" : undefined}
error={form.err.baseURL}
/>
<TextField
label={language.t("provider.custom.field.apiKey.label")}
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
description={language.t("provider.custom.field.apiKey.description")}
value={form.apiKey}
onChange={(v) => setField("apiKey", v)}
/>
</div>
<div class="flex flex-col gap-3">
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
<For each={form.models}>
{(m, i) => (
<div class="flex gap-2 items-start" data-row={m.row}>
<div class="flex-1">
<TextField
label={language.t("provider.custom.models.id.label")}
hideLabel
placeholder={language.t("provider.custom.models.id.placeholder")}
value={m.id}
onChange={(v) => setModel(i(), "id", v)}
validationState={m.err.id ? "invalid" : undefined}
error={m.err.id}
/>
</div>
<div class="flex-1">
<TextField
label={language.t("provider.custom.models.name.label")}
hideLabel
placeholder={language.t("provider.custom.models.name.placeholder")}
value={m.name}
onChange={(v) => setModel(i(), "name", v)}
validationState={m.err.name ? "invalid" : undefined}
error={m.err.name}
/>
</div>
<IconButton
type="button"
icon="trash"
variant="ghost"
class="mt-1.5"
onClick={() => removeModel(i())}
disabled={form.models.length <= 1}
aria-label={language.t("provider.custom.models.remove")}
<div class="flex flex-col gap-3">
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
<For each={form.models}>
{(m, i) => (
<div class="flex gap-2 items-start" data-row={m.row}>
<div class="flex-1">
<TextField
label={language.t("provider.custom.models.id.label")}
hideLabel
placeholder={language.t("provider.custom.models.id.placeholder")}
value={m.id}
onChange={(v) => setModel(i(), "id", v)}
validationState={m.err.id ? "invalid" : undefined}
error={m.err.id}
/>
</div>
)}
</For>
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
{language.t("provider.custom.models.add")}
</Button>
</div>
<div class="flex flex-col gap-3">
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
<For each={form.headers}>
{(h, i) => (
<div class="flex gap-2 items-start" data-row={h.row}>
<div class="flex-1">
<TextField
label={language.t("provider.custom.headers.key.label")}
hideLabel
placeholder={language.t("provider.custom.headers.key.placeholder")}
value={h.key}
onChange={(v) => setHeader(i(), "key", v)}
validationState={h.err.key ? "invalid" : undefined}
error={h.err.key}
/>
</div>
<div class="flex-1">
<TextField
label={language.t("provider.custom.headers.value.label")}
hideLabel
placeholder={language.t("provider.custom.headers.value.placeholder")}
value={h.value}
onChange={(v) => setHeader(i(), "value", v)}
validationState={h.err.value ? "invalid" : undefined}
error={h.err.value}
/>
</div>
<IconButton
type="button"
icon="trash"
variant="ghost"
class="mt-1.5"
onClick={() => removeHeader(i())}
disabled={form.headers.length <= 1}
aria-label={language.t("provider.custom.headers.remove")}
<div class="flex-1">
<TextField
label={language.t("provider.custom.models.name.label")}
hideLabel
placeholder={language.t("provider.custom.models.name.placeholder")}
value={m.name}
onChange={(v) => setModel(i(), "name", v)}
validationState={m.err.name ? "invalid" : undefined}
error={m.err.name}
/>
</div>
)}
</For>
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
{language.t("provider.custom.headers.add")}
</Button>
</div>
<Button
class="w-auto self-start"
type="submit"
size="large"
variant="primary"
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
<IconButton
type="button"
icon="trash"
variant="ghost"
class="mt-1.5"
onClick={() => removeModel(i())}
disabled={form.models.length <= 1}
aria-label={language.t("provider.custom.models.remove")}
/>
</div>
)}
</For>
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
{language.t("provider.custom.models.add")}
</Button>
</form>
</div>
</Dialog>
</div>
<div class="flex flex-col gap-3">
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
<For each={form.headers}>
{(h, i) => (
<div class="flex gap-2 items-start" data-row={h.row}>
<div class="flex-1">
<TextField
label={language.t("provider.custom.headers.key.label")}
hideLabel
placeholder={language.t("provider.custom.headers.key.placeholder")}
value={h.key}
onChange={(v) => setHeader(i(), "key", v)}
validationState={h.err.key ? "invalid" : undefined}
error={h.err.key}
/>
</div>
<div class="flex-1">
<TextField
label={language.t("provider.custom.headers.value.label")}
hideLabel
placeholder={language.t("provider.custom.headers.value.placeholder")}
value={h.value}
onChange={(v) => setHeader(i(), "value", v)}
validationState={h.err.value ? "invalid" : undefined}
error={h.err.value}
/>
</div>
<IconButton
type="button"
icon="trash"
variant="ghost"
class="mt-1.5"
onClick={() => removeHeader(i())}
disabled={form.headers.length <= 1}
aria-label={language.t("provider.custom.headers.remove")}
/>
</div>
)}
</For>
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
{language.t("provider.custom.headers.add")}
</Button>
</div>
<Button
class="w-auto self-start"
type="submit"
size="large"
variant="primary"
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
</Button>
</form>
</div>
)
}
@@ -16,7 +16,7 @@ import { useLocal } from "@/context/local"
import { popularProviders } from "@/hooks/use-providers"
import { useLanguage } from "@/context/language"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { decode64 } from "@/utils/base64"
import { SettingsListV2 } from "./settings-v2/parts/list"
import { SettingsRowV2 } from "./settings-v2/parts/row"
@@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
dialog.show(() => <DialogSelectProvider directory={directory} />)
void dialog.show(() => <DialogConnectProvider directory={directory} />)
}
const providerRank = (id: string) => popularProviders.indexOf(id)
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
@@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => {
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
dialog.show(() => <DialogSelectProvider directory={directory} />)
void dialog.show(() => <DialogConnectProvider directory={directory} />)
}
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
const providerVisible = (providerID: string) =>
@@ -1,329 +1,81 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { useNavigate } from "@solidjs/router"
import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
import { formatKeybind } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteFileEntry,
createCommandPaletteFileOpener,
createCommandPaletteModel,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2"
const DialogSelectFileV2 = lazy(() =>
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
)
type EntryType = "command" | "file" | "session"
type Entry = {
id: string
type: EntryType
title: string
description?: string
keybind?: string
category: string
option?: CommandOption
path?: string
directory?: string
sessionID?: string
archived?: number
updated?: number
}
type DialogSelectFileMode = "all" | "files"
const ENTRY_LIMIT = 5
const COMMON_COMMAND_IDS = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
] as const
const uniqueEntries = (items: Entry[]) => {
const seen = new Set<string>()
const out: Entry[] = []
for (const item of items) {
if (seen.has(item.id)) continue
seen.add(item.id)
out.push(item)
}
return out
}
const createCommandEntry = (option: CommandOption, category: string): Entry => ({
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category,
option,
})
const createFileEntry = (path: string, category: string): Entry => ({
id: "file:" + path,
type: "file",
title: path,
category,
path,
})
const createSessionEntry = (
input: {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
},
category: string,
): Entry => ({
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
})
function createCommandEntries(props: {
filesOnly: () => boolean
command: ReturnType<typeof useCommand>
language: ReturnType<typeof useLanguage>
}) {
const allowed = createMemo(() => {
if (props.filesOnly()) return []
return props.command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const list = createMemo(() => {
const category = props.language.t("palette.group.commands")
return allowed().map((option) => createCommandEntry(option, category))
})
const picks = createMemo(() => {
const all = allowed()
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = props.language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, category))
})
return { allowed, list, picks }
}
function createFileEntries(props: {
file: ReturnType<typeof useFile>
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
language: ReturnType<typeof useLanguage>
}) {
const tabState = createSessionTabs({
tabs: props.tabs,
pathFromTab: props.file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
})
const recent = createMemo(() => {
const all = tabState.openedTabs()
const active = tabState.activeFileTab()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const category = props.language.t("palette.group.files")
const items: Entry[] = []
for (const item of order) {
const path = props.file.pathFromTab(item)
if (!path) continue
if (seen.has(path)) continue
seen.add(path)
items.push(createFileEntry(path, category))
}
return items.slice(0, ENTRY_LIMIT)
})
const root = createMemo(() => {
const category = props.language.t("palette.group.files")
const nodes = props.file.tree.children("")
const paths = nodes
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
})
return { recent, root }
}
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
serverSDK: ServerSDK
language: ReturnType<typeof useLanguage>
}) {
const state: {
token: number
inflight: Promise<Entry[]> | undefined
cached: Entry[] | undefined
} = {
token: 0,
inflight: undefined,
cached: undefined,
}
const sessions = (text: string) => {
const query = text.trim()
if (!query) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as Entry[]
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as Entry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props.serverSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
.filter((s) => !!s?.id)
.map((s) => ({
id: s.id,
title: s.title ?? props.language.t("command.session.new"),
description,
directory,
archived: s.time?.archived,
updated: s.time?.updated,
})),
)
.catch(
() =>
[] as {
id: string
title: string
description: string
directory: string
archived?: number
updated?: number
}[],
)
}),
)
.then((results) => {
if (state.token !== current) return [] as Entry[]
const seen = new Set<string>()
const category = props.language.t("command.category.session")
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, category))
state.cached = next
return next
})
.catch(() => [] as Entry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
return { sessions }
}
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const settings = useSettings()
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const { params, tabs, view } = useSessionLayout()
const filesOnly = () => props.mode === "files"
const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false)
const commandEntries = createCommandEntries({ filesOnly, command, language })
const fileEntries = createFileEntries({ file, tabs, language })
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync().data.path.home)
const label = (directory: string) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
if (!filesOnly() && settings.general.newLayoutDesigns()) {
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
}
const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
}
return <DialogSelectFileLegacy filesOnly={filesOnly} onOpenFile={props.onOpenFile} />
}
function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) {
const language = useLanguage()
const serverSDK = useServerSDK()
const { params } = useSessionLayout()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
return (
<DialogSelectFileV2
server={serverSDK().server}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
onSelect={(result) => {
if (typeof result !== "string") return
openFile(result)
}}
/>
)
}
function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const [grouped, setGrouped] = createSignal(false)
const items = async (text: string) => {
const query = text.trim()
setGrouped(query.length > 0)
if (!query && filesOnly()) {
const loaded = file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : file.tree.list("")
const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
if (!query && props.filesOnly()) {
const loaded = palette.file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : palette.file.tree.list("")
const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
if (loaded || next.length > 0) {
void pending
@@ -331,79 +83,24 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
}
await pending
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
}
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
if (filesOnly()) {
const files = await file.searchFiles(query)
const category = language.t("palette.group.files")
return files.map((path) => createFileEntry(path, category))
if (props.filesOnly()) {
const files = await palette.file.searchFiles(query)
const category = palette.language.t("palette.group.files")
return files.map((path) => createCommandPaletteFileEntry(path, category))
}
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
const category = language.t("palette.group.files")
const entries = files.map((path) => createFileEntry(path, category))
return [...commandEntries.list(), ...nextSessions, ...entries]
}
const handleMove = (item: Entry | undefined) => {
state.cleanup?.()
if (!item) return
if (item.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const open = (path: string) => {
const value = file.tab(path)
void tabs().open(value)
void file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all")
props.onOpenFile?.(path)
tabs().setActive(value)
}
const handleSelect = (item: Entry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") {
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
return
}
if (!item.path) return
open(item.path)
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
return (
<DialogSelectFileV2
server={serverSDK().server}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
onSelect={(result) => {
if (typeof result !== "string") return
open(result)
}}
/>
)
const [files, nextSessions] = await Promise.all([
palette.file.searchFiles(query),
Promise.resolve(palette.sessions(query)),
])
const category = palette.language.t("palette.group.files")
const entries = files.map((path) => createCommandPaletteFileEntry(path, category))
return [...palette.commandEntries(), ...nextSessions, ...entries]
}
return (
@@ -411,21 +108,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
<List
class="px-3"
search={{
placeholder: filesOnly()
? language.t("session.header.searchFiles")
: language.t("palette.search.placeholder"),
placeholder: props.filesOnly()
? palette.language.t("session.header.searchFiles")
: palette.language.t("palette.search.placeholder"),
autofocus: true,
hideIcon: true,
}}
emptyMessage={language.t("palette.empty")}
loadingMessage={language.t("common.loading")}
emptyMessage={palette.language.t("palette.empty")}
loadingMessage={palette.language.t("common.loading")}
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
skipFilter={(item) => item.type === "file"}
groupBy={grouped() ? (item) => item.category : () => ""}
onMove={handleMove}
onSelect={handleSelect}
onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)}
onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)}
>
{(item) => (
<Switch
@@ -452,7 +149,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</Show>
</div>
<Show when={item.keybind}>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", palette.language.t)}</Keybind>
</Show>
</div>
</Match>
@@ -479,7 +176,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</div>
<Show when={item.updated}>
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
{getRelativeTime(new Date(item.updated!).toISOString(), language.t)}
{getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)}
</span>
</Show>
</div>
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test"
import { matchesModelSearch } from "./dialog-select-model-search"
describe("matchesModelSearch", () => {
test("matches model names across separators", () => {
expect(matchesModelSearch("gpt 5", ["GPT-5.5"])).toBe(true)
expect(matchesModelSearch("gpt-5", ["GPT-5.5"])).toBe(true)
expect(matchesModelSearch("gpt5", ["GPT-5.5"])).toBe(true)
})
test("matches any searchable model field", () => {
expect(matchesModelSearch("open ai", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true)
expect(matchesModelSearch("gpt 5", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true)
})
test("does not match unrelated searches", () => {
expect(matchesModelSearch("claude", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(false)
})
})
@@ -0,0 +1,18 @@
export const normalizeModelSearch = (value: string) =>
value
.toLowerCase()
.replace(/[^\p{Letter}\p{Number}]+/gu, " ")
.trim()
.replace(/\s+/g, " ")
export const compactModelSearch = (value: string) => normalizeModelSearch(value).replaceAll(" ", "")
export const matchesModelSearch = (query: string, values: string[]) => {
const search = normalizeModelSearch(query)
if (!search) return true
const compactSearch = compactModelSearch(query)
return values.some(
(value) => normalizeModelSearch(value).includes(search) || compactModelSearch(value).includes(compactSearch),
)
}
@@ -22,17 +22,16 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
const providers = useProviders(directory)
const language = useLanguage()
const connect = (provider: string) => {
const openProviders = (provider?: string) => {
void import("./dialog-connect-provider").then((x) => {
dialog.show(() => <x.DialogConnectProvider provider={provider} directory={directory} />)
const controller = x.useProviderConnectController()
controller.select(provider)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
})
}
const all = () => {
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
})
}
const connect = (provider: string) => openProviders(provider)
const all = () => openProviders()
let listRef: ListRef | undefined
const handleKeyDown = (e: KeyboardEvent) => {
@@ -1,5 +1,15 @@
import { Popover as Kobalte } from "@kobalte/core/popover"
import { Component, ComponentProps, createMemo, For, JSX, Show, ValidComponent } from "solid-js"
import {
Component,
ComponentProps,
createEffect,
createMemo,
For,
JSX,
onCleanup,
Show,
ValidComponent,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -14,9 +24,13 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./dialog-select-model-search"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
@@ -79,6 +93,7 @@ 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}
@@ -142,8 +157,8 @@ export function ModelSelectorPopover(props: {
const handleConnectProvider = () => {
close("provider")
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
void import("./dialog-connect-provider").then((x) => {
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
})
}
const language = useLanguage()
@@ -242,14 +257,9 @@ export function ModelSelectorPopoverV2(props: {
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
)
const models = createMemo(() => {
const search = store.search.trim().toLowerCase()
const search = store.search.trim()
const filtered = search
? allModels().filter(
(item) =>
item.name.toLowerCase().includes(search) ||
item.id.toLowerCase().includes(search) ||
item.provider.name.toLowerCase().includes(search),
)
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
: allModels()
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
@@ -333,19 +343,23 @@ export function ModelSelectorPopoverV2(props: {
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
}
const setSearch = (value: string) => {
const search = value.trim().toLowerCase()
const search = value.trim()
const first = [...allModels()]
.sort((a, b) => a.name.localeCompare(b.name))
.find(
(item) =>
!search ||
item.name.toLowerCase().includes(search) ||
item.id.toLowerCase().includes(search) ||
item.provider.name.toLowerCase().includes(search),
)
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
setStore({ search: value, active: first ? modelKey(first) : manageKey })
}
createEffect(() => {
if (!store.open) return
createEventListener(
document,
"keydown",
(event: KeyboardEvent) => handleDocumentSearchKeydown(searchRef, event, store.search, setSearch),
true,
)
})
return (
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
@@ -434,26 +448,41 @@ export function ModelSelectorPopoverV2(props: {
<MenuV2.RadioGroup value={current()}>
<For each={group.items}>
{(item) => (
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={current() === modelKey(item) ? true : undefined}
class="scroll-my-6"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
setStore("active", modelKey(item))
setTimeout(() => searchRef?.focus())
}}
onSelect={() => selectModel(item)}
<TooltipV2
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={isFree(item.provider.id, item.cost)}
v2
/>
}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
<Show when={item.latest}>
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
</Show>
</MenuV2.RadioItem>
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={current() === modelKey(item) ? true : undefined}
class="scroll-my-6 w-full"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
setStore("active", modelKey(item))
setTimeout(() => searchRef?.focus())
}}
onSelect={() => selectModel(item)}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
<Show when={item.latest}>
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
</Show>
</MenuV2.RadioItem>
</TooltipV2>
)}
</For>
</MenuV2.RadioGroup>
@@ -491,8 +520,8 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
const directory = () => decode64(local.slug())
const provider = () => {
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
void import("./dialog-connect-provider").then((x) => {
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
})
}
@@ -1,87 +0,0 @@
import { type Accessor, Component, Show } from "solid-js"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Tag } from "@opencode-ai/ui/tag"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { useLanguage } from "@/context/language"
import { DialogCustomProvider } from "./dialog-custom-provider"
const CUSTOM_ID = "_custom"
export const DialogSelectProvider: Component<{ directory?: Accessor<string | undefined> }> = (props) => {
const dialog = useDialog()
const providers = useProviders(props.directory)
const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other")
const customLabel = () => language.t("settings.providers.tag.custom")
const note = (id: string) => {
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
if (id === "openai") return language.t("dialog.provider.openai.note")
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
}
return (
<Dialog title={language.t("command.provider.connect")} transition>
<List
class="px-3"
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.provider.empty")}
activeIcon="plus-small"
key={(x) => x?.id}
items={() => {
language.locale()
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
}}
filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
sortBy={(a, b) => {
if (a.id === CUSTOM_ID) return -1
if (b.id === CUSTOM_ID) return 1
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
}}
sortGroupsBy={(a, b) => {
const popular = popularGroup()
if (a.category === popular && b.category !== popular) return -1
if (b.category === popular && a.category !== popular) return 1
return 0
}}
onSelect={(x) => {
if (!x) return
if (x.id === CUSTOM_ID) {
dialog.show(() => <DialogCustomProvider back="providers" directory={props.directory} />)
return
}
dialog.show(() => <DialogConnectProvider provider={x.id} directory={props.directory} />)
}}
>
{(i) => (
<div class="px-1.25 w-full flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
</Show>
<Show when={i.id === CUSTOM_ID}>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</Show>
<Show when={i.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
<Show when={i.id === "opencode-go"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
</div>
)}
</List>
</Dialog>
)
}
@@ -1,22 +1,35 @@
import { Component } from "solid-js"
import { Component, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
import { SettingsServers } from "./settings-servers"
export const DialogSettings: Component = () => {
export const DialogSettings: Component<{ defaultValue?: string }> = (props) => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const showProviders = () => {
void dialog.show(() => <DialogSettings defaultValue="providers" />)
}
return (
<Dialog size="x-large" transition>
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
<Tabs
orientation="vertical"
variant="settings"
value={tab()}
onChange={(value) => void startTransition(() => setTab(value))}
class="h-full settings-dialog"
>
<Tabs.List>
<div class="flex flex-col justify-between h-full w-full gap-4">
<div class="flex flex-col gap-3 w-full pt-3">
@@ -70,7 +83,7 @@ export const DialogSettings: Component = () => {
<SettingsServers />
</Tabs.Content>
<Tabs.Content value="providers" class="no-scrollbar">
<SettingsProviders />
<SettingsProviders onBack={showProviders} />
</Tabs.Content>
<Tabs.Content value="models" class="no-scrollbar">
<SettingsModels />
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test"
import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model"
describe("file tree v2 model", () => {
test("builds sorted depth-first rows", () => {
const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
expect(model.total).toBe(8)
expect(flattenFileTreeV2(model, () => true).map((row) => [row.node.path, row.node.type, row.level])).toEqual([
["docs", "directory", 0],
["docs/guide.md", "file", 1],
["src", "directory", 0],
["src/lib", "directory", 1],
["src/lib/a.ts", "file", 2],
["src/lib/b.ts", "file", 2],
["src/z.ts", "file", 1],
["README.md", "file", 0],
])
})
test("omits descendants of collapsed directories", () => {
const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
"src",
"src/lib",
"src/z.ts",
])
})
test("normalizes separators and duplicate paths", () => {
const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
const rows = flattenFileTreeV2(model, () => true)
expect(model.total).toBe(4)
expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
})
test("supports paths deeper than the legacy recursion limit", () => {
const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts`
const model = buildFileTreeV2Model([file])
expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
})
})
@@ -0,0 +1,77 @@
import type { FileNode } from "@opencode-ai/sdk/v2"
export type FileTreeV2Model = {
children: ReadonlyMap<string, readonly FileTreeV2Node[]>
total: number
}
export type FileTreeV2Node = FileNode & { originalPath: string }
export type FileTreeV2Row = {
node: FileTreeV2Node
level: number
}
export function normalizeFileTreeV2Path(value: string) {
return value
.replaceAll("\\", "/")
.replace(/^\/+|\/+$/g, "")
.replace(/\/{2,}/g, "/")
}
export function buildFileTreeV2Model(paths: readonly string[]): FileTreeV2Model {
const nodes = new Map<string, FileTreeV2Node>()
paths.forEach((value) => {
const file = normalizeFileTreeV2Path(value)
if (!file) return
const parts = file.split("/")
parts.forEach((name, index) => {
const path = parts.slice(0, index + 1).join("/")
if (nodes.has(path)) return
nodes.set(path, {
name,
path,
absolute: path,
type: index === parts.length - 1 ? "file" : "directory",
ignored: false,
originalPath: index === parts.length - 1 ? value : path,
})
})
})
const children = new Map<string, FileTreeV2Node[]>()
nodes.forEach((node) => {
const index = node.path.lastIndexOf("/")
const parent = index === -1 ? "" : node.path.slice(0, index)
const list = children.get(parent)
if (list) list.push(node)
else children.set(parent, [node])
})
children.forEach((nodes) =>
nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
return a.name.localeCompare(b.name)
}),
)
return { children, total: nodes.size }
}
export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: string) => boolean) {
const rows: FileTreeV2Row[] = []
const stack = (model.children.get("") ?? []).toReversed().map((node) => ({ node, level: 0 }))
while (stack.length > 0) {
const row = stack.pop()!
rows.push(row)
if (row.node.type !== "directory" || !expanded(row.node.path)) continue
const children = model.children.get(row.node.path) ?? []
for (let index = children.length - 1; index >= 0; index--) {
stack.push({ node: children[index]!, level: row.level + 1 })
}
}
return rows
}
+145 -298
View File
@@ -1,95 +1,26 @@
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,
createSignal,
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"
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
import { virtualScrollElement } from "@/components/virtual-scroll-element"
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"]) {
@@ -123,7 +54,6 @@ const FileTreeNodeV2 = (
active?: string
draggable: boolean
kinds?: ReadonlyMap<string, Kind>
marks?: Set<string>
as?: "div" | "button"
},
) => {
@@ -133,18 +63,18 @@ const FileTreeNodeV2 = (
"active",
"draggable",
"kinds",
"marks",
"as",
"children",
"class",
"classList",
])
const kind = () => visibleKind(local.node, local.kinds, local.marks)
const kind = () => local.kinds?.get(local.node.path)
return (
<Dynamic
component={local.as ?? "div"}
data-slot="file-tree-v2-row"
data-path={local.node.path}
data-selected={local.node.path === local.active ? "" : undefined}
data-ignored={local.node.ignored ? "" : undefined}
classList={{
@@ -177,244 +107,161 @@ const FileTreeNodeV2 = (
)
}
// 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.
function GuideLines(props: { level: number }) {
return (
<For each={Array.from({ length: props.level })}>
{(_, index) => (
<div
class="absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 group-hover/file-tree-v2:opacity-50"
style={`left: ${guideLineLeft(index())}px`}
/>
)}
</For>
)
}
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 active = () => normalizeFileTreeV2Path(props.active ?? "")
const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? []))
const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true))
const [root, setRoot] = createSignal<HTMLDivElement>()
const [focused, setFocused] = createSignal<string>()
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
get count() {
return rows().length
},
getScrollElement: () => virtualScrollElement(root()),
initialRect: { width: 0, height: 600 },
estimateSize: () => 28,
gap: 2,
overscan: 10,
get getItemKey() {
const current = rows()
return (index: number) => current[index]?.node.path ?? index
},
rangeExtractor: (range) => {
const indexes = defaultRangeExtractor(range)
const path = focused()
const index = path ? rows().findIndex((row) => row.node.path === path) : -1
if (index < 0 || indexes.includes(index)) return indexes
return [...indexes, index].sort((a, b) => a - b)
},
})
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,
const path = active()
if (!path) return
const index = rows().findIndex((row) => row.node.path === path)
if (index < 0) return
queueMicrotask(() => {
if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
virtualizer.scrollToIndex(index, { align: "auto" })
})
// 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 rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
const virtualItemByKey = createMemo(
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
)
const nodes = createMemo(() => visibleNodesForPath(props.path, file.tree.children, filter()))
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
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}
<div
ref={setRoot}
data-component="file-tree-v2"
data-total-rows={model().total}
class="group/file-tree-v2"
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
>
<For each={virtualRowKeys()}>
{(key) => (
<Show when={virtualItemByKey().get(key)}>
{(item) => (
<div
style={{
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,
}}
>
<Show when={rowByKey().get(key as string)}>
{(row) => (
<Show
when={row().node.type === "directory"}
fallback={
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
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>
)
}}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() =>
props.onFileClick?.({
...row().node,
path: row().node.originalPath,
absolute: row().node.originalPath,
})
}
>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
<div class="w-4 shrink-0" />
</Show>
<span class="filetree-iconpair size-4">
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</FileTreeNodeV2>
}
>
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
kinds={props.kinds}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
aria-expanded={file.tree.state(row().node.path)?.expanded ?? true}
onClick={() =>
file.tree.state(row().node.path)?.expanded === false
? file.tree.expand(row().node.path, { list: false })
: file.tree.collapse(row().node.path)
}
>
<GuideLines level={row().level} />
<div
data-slot="file-tree-v2-chevron"
data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""}
class="size-4 flex items-center justify-center"
>
<Icon name="chevron-down" />
</div>
</FileTreeNodeV2>
</Show>
)}
</Show>
</div>
)}
</Show>
)}
</For>
</div>
)
@@ -201,6 +201,7 @@ export default function FileTree(props: {
kinds?: ReadonlyMap<string, Kind>
draggable?: boolean
onFileClick?: (file: FileNode) => void
onFileDoubleClick?: (file: FileNode) => void
_filter?: Filter
_marks?: Set<string>
@@ -440,6 +441,7 @@ export default function FileTree(props: {
active={props.active}
draggable={props.draggable}
onFileClick={props.onFileClick}
onFileDoubleClick={props.onFileDoubleClick}
_filter={filter()}
_marks={marks()}
_deeps={deeps()}
@@ -462,6 +464,7 @@ export default function FileTree(props: {
as="button"
type="button"
onClick={() => props.onFileClick?.(node)}
onDblClick={() => props.onFileDoubleClick?.(node)}
>
<div class="w-4 shrink-0" />
<Switch>
+35 -2
View File
@@ -1,4 +1,4 @@
import { Show, type Component } from "solid-js"
import { Show, type Component, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
@@ -23,7 +23,18 @@ type ModelInfo = {
}
}
export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean }> = (props) => {
function ModelTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
return (
<div class="flex min-w-0 items-center gap-4">
<span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
<span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
</div>
)
}
export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean; v2?: boolean }> = (
props,
) => {
const language = useLanguage()
const sourceName = (model: ModelInfo) => {
const value = `${model.id} ${model.name}`.toLowerCase()
@@ -51,6 +62,13 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${sourceName(props.model)} ${props.model.name}${suffix}`
}
const name = () => {
const tags: Array<string> = []
if (props.latest) tags.push(language.t("model.tag.latest"))
if (props.free) tags.push(language.t("model.tag.free"))
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${props.model.name}${suffix}`
}
const inputs = () => {
if (props.model.capabilities) {
const input = props.model.capabilities.input
@@ -73,6 +91,21 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
: language.t("model.tooltip.reasoning.none")
}
const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() })
const contextLimit = () => props.model.limit.context.toLocaleString(language.intl())
if (props.v2) {
return (
<div class="flex w-[180px] flex-col gap-2">
<ModelTooltipRow name={language.t("model.tooltip.model")} value={name()} />
<ModelTooltipRow name={language.t("model.tooltip.provider")} value={props.model.provider.name} />
<Show when={inputs()}>
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
</Show>
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
</div>
)
}
return (
<div class="flex flex-col gap-1 py-1">
+3 -1
View File
@@ -218,6 +218,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement
let restoreEndOnFocus = true
let savedCursor: number | null = null
const mirror = { input: false }
const inset = 56
@@ -590,7 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const restoreFocus = () => {
requestAnimationFrame(() => {
const cursor = prompt.cursor() ?? promptLength(prompt.current())
const cursor = savedCursor ?? prompt.cursor() ?? promptLength(prompt.current())
editorRef.focus()
setCursorPosition(editorRef, cursor)
queueScroll()
@@ -627,6 +628,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const handleBlur = () => {
savedCursor = currentCursor()
closePopover()
setComposing(false)
}
@@ -1,7 +1,7 @@
import { Component, For, Show } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
import type { ContextItem } from "@/context/prompt"
@@ -27,7 +27,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
const selected = props.active(item)
return (
<Tooltip
<TooltipV2
value={
<span class="flex max-w-[300px]">
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
@@ -78,7 +78,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
</Show>
</div>
</Tooltip>
</TooltipV2>
)
}}
</For>
@@ -1,4 +1,4 @@
import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
import { createEffect, For, onCleanup, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
@@ -8,6 +8,7 @@ import { getProjectAvatarVariant } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { pathKey } from "@/utils/path-key"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
export type PromptProject = {
name?: string
@@ -101,6 +102,16 @@ export function createPromptProjectController(input: {
setStore({ open: false, search: "", active: "" })
input.controls().add(language.t("command.project.open"), server)
}
const setSearch = (value: string) => {
const search = value.trim().toLowerCase()
const first = input
.controls()
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
setStore({
search: value,
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
})
}
return {
selected,
@@ -127,16 +138,7 @@ export function createPromptProjectController(input: {
}
setStore({ open: false, search: "", active: "" })
},
setSearch(value: string) {
const search = value.trim().toLowerCase()
const first = input
.controls()
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
setStore({
search: value,
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
})
},
setSearch,
clearSearch() {
setStore({ search: "", active: initialActive() })
setTimeout(() => searchRef?.focus())
@@ -170,6 +172,9 @@ export function createPromptProjectController(input: {
focusSearch() {
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
},
handleSearchKeydown(event: KeyboardEvent) {
return handleDocumentSearchKeydown(searchRef, event, store.search, setSearch)
},
}
}
@@ -243,6 +248,13 @@ export function PromptProjectSelector(props: {
return project ? props.controller.projectKey(project) : undefined
}
createEffect(() => {
if (!props.controller.open()) return
const handler = (event: KeyboardEvent) => props.controller.handleSearchKeydown(event)
document.addEventListener("keydown", handler, true)
onCleanup(() => document.removeEventListener("keydown", handler, true))
})
return (
<DropdownMenu
open={props.controller.open()}
@@ -4,6 +4,7 @@ import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
import { Button } from "@opencode-ai/ui/button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { createMediaQuery } from "@solid-primitives/media"
import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout"
@@ -14,6 +15,7 @@ import { useSDK } from "@/context/sdk"
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSettings } from "@/context/settings"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
@@ -47,8 +49,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const layout = useLayout()
const language = useLanguage()
const sdk = useSDK()
const settings = useSettings()
const providers = useProviders(() => sdk().directory)
const { params, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const variant = createMemo(() => props.variant ?? "button")
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
@@ -56,6 +60,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
})
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
@@ -31,6 +31,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
import { useTitlebarRightMount } from "../titlebar"
const OPEN_APPS = [
"vscode",
@@ -284,10 +285,9 @@ export function SessionHeader() {
}
const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
const [rightMount, setRightMount] = createSignal<HTMLElement | null>(null)
const rightMount = useTitlebarRightMount()
onMount(() => {
setCenterMount(document.getElementById("opencode-titlebar-center"))
setRightMount(document.getElementById("opencode-titlebar-right"))
})
return (
@@ -10,7 +10,7 @@ import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element {
return (
<div class="flex items-center gap-x-1.5 min-w-0">
<Show
@@ -22,12 +22,19 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
<FileIcon node={{ path: props.path, type: "file" }} mono class="absolute inset-0 size-4 tab-fileicon-mono" />
</span>
</Show>
<span class="text-14-medium truncate">{getFilename(props.path)}</span>
<span class="text-14-medium truncate" classList={{ italic: props.temporary }}>
{getFilename(props.path)}
</span>
</div>
)
}
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
export function SortableTab(props: {
tab: string
temporary?: boolean
onTabClose: (tab: string) => void
onTabDoubleClick?: (tab: string) => void
}): JSX.Element {
const file = useFile()
const language = useLanguage()
const command = useCommand()
@@ -36,7 +43,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
const content = createMemo(() => {
const value = path()
if (!value) return
return <FileVisual path={value} />
return <FileVisual path={value} temporary={props.temporary} />
})
return (
<div use:sortable class="h-full flex items-center" classList={{ "opacity-0": sortable.isActiveDraggable }}>
@@ -61,6 +68,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
>
<Show when={content()}>{(value) => value()}</Show>
</Tabs.Trigger>
@@ -0,0 +1,271 @@
import type { JSX } from "solid-js"
import { Show, createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useSortable } from "@dnd-kit/solid/sortable"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tabs } from "@opencode-ai/ui/tabs"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLanguage } from "@/context/language"
import { focusTerminalById } from "@/pages/session/helpers"
export function SortableTerminalTabV2(props: {
terminal: LocalPTY
index: () => number
newLayout: boolean
onClose?: () => void
}): JSX.Element {
const terminal = useTerminal()
const language = useLanguage()
const sortable = useSortable({
get id() {
return props.terminal.id
},
get index() {
return props.index()
},
})
const [store, setStore] = createStore({
editing: false,
title: props.terminal.title,
menuOpen: false,
menuPosition: { x: 0, y: 0 },
blurEnabled: false,
})
let input: HTMLInputElement | undefined
let blurFrame: number | undefined
let editRequested = false
const isDefaultTitle = () => {
const number = props.terminal.titleNumber
if (!Number.isFinite(number) || number <= 0) return false
return isDefaultTerminalTitle(props.terminal.title, number)
}
const label = () => {
language.locale()
if (props.terminal.title && !isDefaultTitle()) return props.terminal.title
const number = props.terminal.titleNumber
if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number })
if (props.terminal.title) return props.terminal.title
return language.t("terminal.title")
}
const close = () => {
const count = terminal.all().length
void terminal.close(props.terminal.id)
if (count === 1) {
props.onClose?.()
}
}
const focus = () => {
if (store.editing) return
terminal.open(props.terminal.id)
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
focusTerminalById(props.terminal.id)
}
const edit = (e?: Event) => {
if (e) {
e.stopPropagation()
e.preventDefault()
}
setStore("blurEnabled", false)
setStore("title", props.terminal.title)
setStore("editing", true)
}
const save = () => {
if (!store.blurEnabled) return
const value = store.title.trim()
if (value && value !== props.terminal.title) {
terminal.update({ id: props.terminal.id, title: value })
}
setStore("editing", false)
}
const keydown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
save()
return
}
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
}
}
const menu = (e: MouseEvent) => {
e.preventDefault()
setStore("menuPosition", { x: e.clientX, y: e.clientY })
setStore("menuOpen", true)
}
createEffect(() => {
if (!store.editing) return
if (!input) return
input.focus()
input.select()
if (blurFrame !== undefined) cancelAnimationFrame(blurFrame)
blurFrame = requestAnimationFrame(() => {
blurFrame = undefined
setStore("blurEnabled", true)
})
})
onCleanup(() => {
if (blurFrame === undefined) return
cancelAnimationFrame(blurFrame)
})
return (
<div
ref={sortable.ref}
class="outline-none focus:outline-none focus-visible:outline-none"
classList={{
"h-full flex items-center": props.newLayout,
"h-full": !props.newLayout,
}}
>
<Show
when={props.newLayout}
fallback={
<div class="relative h-full">
<Tabs.Trigger
value={props.terminal.id}
onClick={focus}
onMouseDown={(e) => e.preventDefault()}
onContextMenu={menu}
class="!shadow-none"
classes={{
button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0",
}}
closeButton={
<IconButton
icon="close"
variant="ghost"
onClick={(e) => {
e.stopPropagation()
close()
}}
aria-label={language.t("terminal.close")}
/>
}
>
<span onDblClick={edit} classList={{ invisible: store.editing }}>
{label()}
</span>
</Tabs.Trigger>
<Show when={store.editing}>
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
<input
ref={input}
type="text"
value={store.title}
onInput={(e) => setStore("title", e.currentTarget.value)}
onBlur={save}
onKeyDown={keydown}
onMouseDown={(e) => e.stopPropagation()}
class="bg-transparent border-none outline-none text-sm min-w-0 flex-1"
/>
</div>
</Show>
<DropdownMenu open={store.menuOpen} onOpenChange={(open) => setStore("menuOpen", open)}>
<DropdownMenu.Portal>
<DropdownMenu.Content
class="fixed"
style={{
left: `${store.menuPosition.x}px`,
top: `${store.menuPosition.y}px`,
}}
onCloseAutoFocus={(e) => {
if (!editRequested) return
e.preventDefault()
editRequested = false
requestAnimationFrame(() => edit())
}}
>
<DropdownMenu.Item onSelect={() => (editRequested = true)}>
<Icon name="edit" class="w-4 h-4 mr-2" />
{language.t("common.rename")}
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={close}>
<Icon name="close" class="w-4 h-4 mr-2" />
{language.t("common.close")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
}
>
<MenuV2.Context>
<MenuV2.Context.Trigger class="relative" as="div">
<Tabs.Trigger
value={props.terminal.id}
onClick={focus}
closeButton={
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={(e) => {
e.stopPropagation()
close()
}}
aria-label={language.t("terminal.close")}
/>
}
hideCloseButton
onMiddleClick={close}
>
<span
class="truncate"
data-slot="terminal-tab-title"
onDblClick={edit}
classList={{ invisible: store.editing }}
>
{label()}
</span>
</Tabs.Trigger>
<Show when={store.editing}>
<div class="absolute inset-0 flex items-center bg-v2-background-bg-layer-01 z-10 pointer-events-auto rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-muted)] px-2">
<input
ref={input}
type="text"
value={store.title}
onInput={(e) => setStore("title", e.currentTarget.value)}
onBlur={save}
onKeyDown={keydown}
onMouseDown={(e) => e.stopPropagation()}
class="bg-transparent border-none outline-none min-w-0 flex-1 p-0 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:440] [font-variation-settings:'slnt'_0] [font-variant-numeric:tabular-nums]"
/>
</div>
</Show>
</MenuV2.Context.Trigger>
<MenuV2.Context.Portal>
<MenuV2.Context.Content
onCloseAutoFocus={(e) => {
if (!editRequested) return
e.preventDefault()
editRequested = false
requestAnimationFrame(() => edit())
}}
>
<MenuV2.Item onSelect={() => (editRequested = true)}>{language.t("common.rename")}</MenuV2.Item>
<MenuV2.Item onSelect={close}>{language.t("common.close")}</MenuV2.Item>
</MenuV2.Context.Content>
</MenuV2.Context.Portal>
</MenuV2.Context>
</Show>
</div>
)
}
@@ -440,11 +440,6 @@ export const SettingsGeneral: Component = () => {
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
@@ -471,11 +466,6 @@ export const SettingsGeneral: Component = () => {
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
@@ -5,24 +5,18 @@ import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast"
import fuzzysort from "fuzzysort"
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { SettingsList } from "./settings-list"
import { SettingsListV2 } from "./settings-v2/parts/list"
const ButtonV2 = lazy(() => import("@opencode-ai/ui/v2/button-v2").then((module) => ({ default: module.ButtonV2 })))
const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon })))
const IconButtonV2 = lazy(() =>
import("@opencode-ai/ui/v2/icon-button-v2").then((module) => ({ default: module.IconButtonV2 })),
)
const TextInputV2 = lazy(() =>
import("@opencode-ai/ui/v2/text-input-v2").then((module) => ({ default: module.TextInputV2 })),
)
const SettingsListV2 = lazy(() =>
import("./settings-v2/parts/list").then((module) => ({ default: module.SettingsListV2 })),
)
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
@@ -8,8 +8,7 @@ import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
@@ -28,20 +27,26 @@ const PROVIDER_NOTES = [
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
] as const
export const SettingsProviders: Component = () => {
export const SettingsProviders: Component<{ onBack?: () => void }> = (props) => {
return (
<SettingsServerScope>
<SettingsProvidersContent />
<SettingsProvidersContent onBack={props.onBack} />
</SettingsServerScope>
)
}
const SettingsProvidersContent: Component = () => {
const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const providers = useProviders()
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => {
providerConnect.select(provider)
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
}
const connected = createMemo(() => {
return providers
@@ -206,14 +211,7 @@ const SettingsProvidersContent: Component = () => {
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
</Show>
</div>
<Button
size="large"
variant="secondary"
icon="plus-small"
onClick={() => {
dialog.show(() => <DialogConnectProvider provider={item.id} />)
}}
>
<Button size="large" variant="secondary" icon="plus-small" onClick={() => connect(item.id)}>
{language.t("common.connect")}
</Button>
</div>
@@ -239,7 +237,7 @@ const SettingsProvidersContent: Component = () => {
variant="secondary"
icon="plus-small"
onClick={() => {
dialog.show(() => <DialogCustomProvider back="close" />)
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
@@ -250,9 +248,7 @@ const SettingsProvidersContent: Component = () => {
<Button
variant="ghost"
class="px-0 py-0 mt-5 text-14-medium text-text-interactive-base text-left justify-start hover:bg-transparent active:bg-transparent"
onClick={() => {
dialog.show(() => <DialogSelectProvider />)
}}
onClick={() => connect()}
>
{language.t("dialog.provider.viewAll")}
</Button>
@@ -1,4 +1,4 @@
import { Component } from "solid-js"
import { Component, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { Icon } from "@opencode-ai/ui/icon"
@@ -10,16 +10,30 @@ import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models"
import "./settings-v2.css"
import { SettingsServersV2 } from "./servers"
import { useDialog } from "@opencode-ai/ui/context/dialog"
export const DialogSettings: Component<{
sessionID?: string
defaultValue?: string
}> = (props) => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const showProviders = () => {
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
}
return (
<Dialog size="x-large" variant="settings" class="settings-v2-dialog">
<TabsV2 orientation="vertical" variant="settings" defaultValue="general" class="settings-v2">
<TabsV2
orientation="vertical"
variant="settings"
value={tab()}
onChange={(value) => void startTransition(() => setTab(value))}
class="settings-v2"
>
<TabsV2.List>
<div class="flex flex-col justify-between h-full w-full">
<div class="flex flex-col gap-3 w-full">
@@ -73,7 +87,7 @@ export const DialogSettings: Component<{
<SettingsServersV2 />
</TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 />
<SettingsProvidersV2 onBack={showProviders} />
</TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 />
@@ -422,11 +422,6 @@ export const SettingsGeneralV2: Component<{
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
/>
</SettingsRowV2>
@@ -454,11 +449,6 @@ export const SettingsGeneralV2: Component<{
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
/>
</SettingsRowV2>
@@ -8,8 +8,7 @@ import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider } from "../dialog-connect-provider"
import { DialogSelectProvider } from "../dialog-select-provider"
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
import { DialogCustomProvider } from "../dialog-custom-provider"
import { SettingsListV2 } from "./parts/list"
import "./settings-v2.css"
@@ -30,12 +29,18 @@ const PROVIDER_NOTES = [
const PROVIDER_ICON_SIZE = 16
export const SettingsProvidersV2: Component = () => {
export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const providers = useProviders()
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => {
providerConnect.select(provider)
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
}
const connected = createMemo(() => {
return providers
@@ -206,14 +211,7 @@ export const SettingsProvidersV2: Component = () => {
</Show>
</div>
</div>
<ButtonV2
size="normal"
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => <DialogConnectProvider provider={item.id} />)
}}
>
<ButtonV2 size="normal" variant="neutral" icon="plus" onClick={() => connect(item.id)}>
{language.t("common.connect")}
</ButtonV2>
</div>
@@ -241,7 +239,7 @@ export const SettingsProvidersV2: Component = () => {
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => <DialogCustomProvider back="close" />)
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
@@ -249,13 +247,7 @@ export const SettingsProvidersV2: Component = () => {
</div>
</SettingsListV2>
<button
type="button"
class="settings-v2-providers-view-all"
onClick={() => {
dialog.show(() => <DialogSelectProvider />)
}}
>
<button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}>
{language.t("dialog.provider.viewAll")}
</button>
</div>
@@ -399,7 +399,7 @@
overflow: hidden;
font-size: 13px;
font-weight: 440;
line-height: 1;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -246,18 +246,23 @@ export function TabNavItem(props: {
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<Show when={props.session()}>
{(session) => (
<span data-slot="project-avatar-slot">
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show
when={props.session()}
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
}
>
{(session) => (
<SessionTabAvatar
project={project()}
directory={session().directory}
sessionId={session().id}
server={props.server}
/>
</span>
)}
</Show>
)}
</Show>
</span>
<span
ref={(el) => {
titleEl = el
@@ -3,7 +3,7 @@ import { createSignal, Show, type JSXElement } from "solid-js"
import "./titlebar-tab-popover.css"
// Initial hover delay before the preview appears, per design.
const OPEN_DELAY = 200
const OPEN_DELAY = 2_000
// Mouse-out delay: begin closing immediately (a brief exit animation plays).
const CLOSE_DELAY = 0
// After a preview closes, hovering a neighbouring tab within this window skips
@@ -17,8 +17,6 @@ import { createTabPromptState } from "@/context/prompt"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
const sortableTransition = { duration: 0 }
function SessionTabSlot(props: {
tab: SessionTab
id: string
@@ -42,6 +40,7 @@ function SessionTabSlot(props: {
let ref!: HTMLDivElement
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id])
const [loadedSession] = createResource(
() => {
const ctx = props.serverCtx()
@@ -73,8 +72,10 @@ function SessionTabSlot(props: {
createEffect(() => {
const value = session()
if (!value) return
tabs.rememberSessionInfo(props.tab, value)
const current = sdk()
if (!value || !current) return
if (!current) return
createTabPromptState(tabs, props.tab, current.scope, {
dir: base64Encode(value.directory),
id: value.id,
@@ -88,7 +89,7 @@ function SessionTabSlot(props: {
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ hidden: !session() && !missingSession() }}
classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
>
<TabNavItem
ref={(el) => {
@@ -97,7 +98,7 @@ function SessionTabSlot(props: {
href={tabHref(props.tab)}
server={props.tab.server}
session={session}
fallbackTitle={missingSession() ? language.t("session.tab.unknown") : undefined}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onTitleChange={(title) => {
const value = session()
const ctx = props.serverCtx()
+31 -3
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createResource, createSignal, Match, Show, Switch, untrack } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -60,6 +60,12 @@ export type TitlebarUpdate = {
install: () => void
}
export function useTitlebarRightMount() {
const [mount, setMount] = createSignal<HTMLElement | null>(null)
onMount(() => setMount(document.getElementById("opencode-titlebar-right")))
return mount
}
export function Titlebar(props: { update?: TitlebarUpdate }) {
const layout = useLayout()
const platform = usePlatform()
@@ -328,6 +334,21 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return
}
if (route.type === "home") {
const selection = layout.home.selection()
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
const project = conn
? global
.ensureServerCtx(conn)
.projects.list()
.find((item) => item.worktree === selection.directory)
: undefined
if (conn && project) {
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
return
}
}
const current = layout.projects.list()[0]
if (current) {
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
@@ -374,9 +395,16 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
keybind: "mod+w",
hidden: true,
onSelect: () => {
tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab))
tabsStoreActions.closeTab(tabsStore.findIndex((tab) => current === tab))
},
},
{
id: "tab.reopenClosed",
category: language.t("command.category.file"),
title: language.t("command.tab.reopenClosed"),
keybind: "mod+shift+t",
onSelect: () => tabsStoreActions.reopenClosedTab(),
},
{
id: `tab.prev`,
category: "tab",
@@ -464,7 +492,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.removeTab(index)
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
@@ -0,0 +1,18 @@
import { expect, test } from "bun:test"
import { virtualScrollElement } from "./virtual-scroll-element"
test("resolves the connected viewport that owns the virtual root", () => {
const stale = document.createElement("div")
stale.className = "scroll-view__viewport"
const viewport = document.createElement("div")
viewport.className = "scroll-view__viewport"
const root = document.createElement("div")
viewport.append(root)
document.body.append(viewport)
expect(virtualScrollElement(root)).toBe(viewport)
expect(virtualScrollElement(root)).not.toBe(stale)
viewport.remove()
expect(virtualScrollElement(root)).toBeNull()
})
@@ -0,0 +1,4 @@
export function virtualScrollElement(root: HTMLElement | undefined) {
if (!root?.isConnected) return null
return root.closest<HTMLDivElement>(".scroll-view__viewport")
}
+40
View File
@@ -0,0 +1,40 @@
import type { SessionTab, Tab } from "./tabs"
export type ClosedTab = {
tab: SessionTab
index: number
}
const CLOSED_TAB_LIMIT = 25
// Only session tabs are recorded; closing a draft tab deletes its persisted
// state, so a reopened draft would come back empty anyway.
export function pushClosedTab(stack: ClosedTab[], tab: Tab, index: number): ClosedTab[] {
if (tab.type !== "session") return stack
return [...stack, { tab: { ...tab }, index }].slice(-CLOSED_TAB_LIMIT)
}
// Pops the most recently closed tab that is not open again,
// discarding stale entries along the way.
export function takeClosedTab(stack: ClosedTab[], tabs: Tab[]): { entry?: ClosedTab; stack: ClosedTab[] } {
const remaining = [...stack]
while (remaining.length) {
const entry = remaining.pop()
if (entry && !isOpen(tabs, entry.tab)) return { entry, stack: remaining }
}
return { stack: remaining }
}
export function removeClosedTabs(stack: ClosedTab[], server: SessionTab["server"], sessionIDs: string[]) {
const removed = new Set(sessionIDs)
return stack.filter((entry) => entry.tab.server !== server || !removed.has(entry.tab.sessionId))
}
export function nextTabAfterClose(tabs: Tab[], index: number, active: boolean) {
if (!active) return undefined
return tabs[index + 1] ?? tabs[index - 1] ?? null
}
function isOpen(tabs: Tab[], tab: SessionTab) {
return tabs.some((item) => item.type === "session" && item.server === tab.server && item.sessionId === tab.sessionId)
}
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { upsertCommandRegistration } from "./command"
import { resolveKeybindOption, upsertCommandRegistration } from "./command"
describe("upsertCommandRegistration", () => {
test("replaces keyed registrations", () => {
@@ -23,3 +23,19 @@ describe("upsertCommandRegistration", () => {
expect(next[1]?.options).toBe(one)
})
})
describe("resolveKeybindOption", () => {
test("prefers a matching contextual command over the global fallback", () => {
const fallback = { id: "tab.close", title: "Close tab" }
const contextual = { id: "terminal.close", title: "Close terminal", when: () => true }
expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(contextual)
})
test("uses the global fallback outside the command context", () => {
const fallback = { id: "tab.close", title: "Close tab" }
const contextual = { id: "terminal.close", title: "Close terminal", when: () => false }
expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(fallback)
})
})
+16 -5
View File
@@ -82,10 +82,15 @@ export interface CommandOption {
suggested?: boolean
disabled?: boolean
hidden?: boolean
when?: (event: KeyboardEvent) => boolean
onSelect?: (source?: "palette" | "keybind" | "slash") => void
onHighlight?: () => (() => void) | void
}
export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
}
type CommandSource = "palette" | "keybind" | "slash"
export type CommandCatalogItem = {
@@ -334,7 +339,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
})
const keymap = createMemo(() => {
const map = new Map<string, CommandOption>()
const map = new Map<string, CommandOption[]>()
for (const option of options()) {
if (option.id.startsWith(SUGGESTED_PREFIX)) continue
if (option.disabled) continue
@@ -344,8 +349,12 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
for (const kb of keybinds) {
if (!kb.key) continue
const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)
if (map.has(sig)) continue
map.set(sig, option)
const existing = map.get(sig)
if (existing) {
existing.push(option)
continue
}
map.set(sig, [option])
}
}
return map
@@ -374,7 +383,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const sig = signatureFromEvent(event)
const isPalette = palette().has(sig)
const option = keymap().get(sig)
const option = resolveKeybindOption(keymap().get(sig), event)
const modified = event.ctrlKey || event.metaKey || event.altKey
const isTab = event.key === "Tab"
@@ -383,17 +392,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
if (isPalette) {
event.preventDefault()
event.stopPropagation()
showPalette()
return
}
if (!option) return
event.preventDefault()
event.stopPropagation()
option.onSelect?.("keybind")
}
onMount(() => {
makeEventListener(document, "keydown", handleKeyDown)
makeEventListener(document, "keydown", handleKeyDown, { capture: true })
})
function register(cb: () => CommandOption[]): void
+8 -4
View File
@@ -203,12 +203,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return promise
}
const search = (query: string, dirs: "true" | "false") =>
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
sdk()
.client.find.files({ query, dirs })
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
.then(
(x) => (x.data ?? []).map(path.normalize),
() => [],
(error) => {
if (options?.signal?.aborted) throw error
return []
},
)
const stop = sdk().event.listen((e) => {
@@ -284,7 +287,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setScrollLeft,
selectedLines,
setSelectedLines,
searchFiles: (query: string) => search(query, "false"),
searchFiles: (query: string, options?: { limit?: number; signal?: AbortSignal }) =>
search(query, "false", options),
searchFilesAndDirectories: (query: string) => search(query, "true"),
}
},
+11 -1
View File
@@ -1,7 +1,8 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createEffect, createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerProjects, ServerConnection, useServer } from "./server"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { pathKey } from "@/utils/path-key"
import { useServerHealth } from "@/utils/server-health"
import { createServerSdkContext } from "./server-sdk"
import { createServerSyncContext } from "./server-sync"
@@ -127,6 +128,14 @@ function createServerCtx(
}
const projectsList = createMemo(() => projects.list().map(enrich))
const recentlyClosedList = createMemo(() => {
const known = new Set(sync.data.project.map((project) => pathKey(project.worktree)))
return projects
.recentlyClosed()
.filter((worktree) => known.has(pathKey(worktree)))
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
.map((worktree) => enrich({ worktree, expanded: false }))
})
const isLocal =
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
@@ -139,6 +148,7 @@ function createServerCtx(
projects: {
...projects,
list: projectsList,
recentlyClosed: recentlyClosedList,
},
}
}
@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test"
import {
SESSION_OPEN_FILE_TAB,
closeSessionTab,
openSessionTab,
previewSessionTab,
type SessionTabState,
} from "./layout-tabs"
const state = (all: string[], active?: string, preview?: string): SessionTabState => ({
tabs: { all, active },
preview,
})
describe("previewSessionTab", () => {
test("appends the Open File placeholder", () => {
expect(previewSessionTab(state(["file://a.ts"], "file://a.ts"), SESSION_OPEN_FILE_TAB)).toEqual(
state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
)
})
test("replaces the current preview in place", () => {
expect(
previewSessionTab(
state(["context", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
"file://a.ts",
),
).toEqual(state(["context", "file://a.ts", "file://b.ts"], "file://a.ts", "file://a.ts"))
})
test("activates a durable tab without duplicating it", () => {
expect(
previewSessionTab(
state(["file://a.ts", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
"file://b.ts",
),
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts"))
})
test("replaces a restored Open File placeholder", () => {
expect(
previewSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts"),
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts", "file://b.ts"))
})
})
describe("openSessionTab", () => {
test("pins the current preview", () => {
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://a.ts")).toEqual(
state(["file://a.ts"], "file://a.ts"),
)
})
test("replaces a preview with a directly opened file", () => {
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://b.ts")).toEqual(
state(["file://b.ts"], "file://b.ts"),
)
})
test("keeps the preview when switching to Review", () => {
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "review")).toEqual(
state(["file://a.ts"], "review", "file://a.ts"),
)
})
test("replaces a restored Open File placeholder with a direct open", () => {
expect(openSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts")).toEqual(
state(["file://a.ts", "file://b.ts"], "file://b.ts"),
)
})
})
describe("closeSessionTab", () => {
test("clears preview metadata and selects the left neighbor", () => {
expect(
closeSessionTab(
state(["file://a.ts", "file://b.ts", "file://c.ts"], "file://b.ts", "file://b.ts"),
"file://b.ts",
),
).toEqual(state(["file://a.ts", "file://c.ts"], "file://a.ts"))
})
})
+103
View File
@@ -0,0 +1,103 @@
export const SESSION_OPEN_FILE_TAB = "open-file"
export type SessionTabs = {
active?: string
all: string[]
}
export type SessionTabState = {
tabs: SessionTabs
preview?: string
}
const sessionTabPreview = (current: SessionTabState) =>
current.preview ?? (current.tabs.all.includes(SESSION_OPEN_FILE_TAB) ? SESSION_OPEN_FILE_TAB : undefined)
export function previewSessionTab(current: SessionTabState, tab: string): SessionTabState {
const preview = sessionTabPreview(current)
const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
const existingIndex = current.tabs.all.indexOf(tab)
if (existingIndex !== -1) {
if (previewIndex === -1 || preview === tab) {
return { tabs: { all: current.tabs.all, active: tab }, preview: preview === tab ? tab : undefined }
}
return {
tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
}
}
if (previewIndex === -1) {
return { tabs: { all: [...current.tabs.all, tab], active: tab }, preview: tab }
}
return {
tabs: {
all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
active: tab,
},
preview: tab,
}
}
export function openSessionTab(current: SessionTabState, tab: string): SessionTabState {
const preview = sessionTabPreview(current)
if (tab === "review") {
return {
tabs: { all: current.tabs.all.filter((item) => item !== tab), active: tab },
preview,
}
}
if (tab === "context") {
return {
tabs: { all: [tab, ...current.tabs.all.filter((item) => item !== tab)], active: tab },
preview,
}
}
const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
const existingIndex = current.tabs.all.indexOf(tab)
if (existingIndex !== -1) {
if (previewIndex === -1 || preview === tab) {
return { tabs: { all: current.tabs.all, active: tab } }
}
return {
tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
}
}
if (previewIndex === -1) {
return { tabs: { all: [...current.tabs.all, tab], active: tab } }
}
return {
tabs: {
all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
active: tab,
},
}
}
export function closeSessionTab(current: SessionTabState, tab: string): SessionTabState {
if (tab === "review") {
if (current.tabs.active !== tab) return current
return {
tabs: { all: current.tabs.all, active: current.tabs.all[0] },
preview: current.preview,
}
}
const all = current.tabs.all.filter((item) => item !== tab)
const preview = current.preview === tab ? undefined : current.preview
if (current.tabs.active !== tab) return { tabs: { ...current.tabs, all }, preview }
const index = current.tabs.all.indexOf(tab)
return {
tabs: {
all,
active: current.tabs.all[index - 1] ?? current.tabs.all[index + 1] ?? all[0],
},
preview,
}
}
+93 -41
View File
@@ -5,10 +5,11 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useServerSync } from "./server-sync"
import { useServerSDK } from "./server-sdk"
import { ServerConnection, useServer } from "./server"
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key"
import { decode64 } from "@/utils/base64"
import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
@@ -18,6 +19,7 @@ import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/u
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
import { requireServerKey } from "@/utils/session-route"
import { type DraftTab, useTabs } from "./tabs"
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -54,14 +56,11 @@ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
return "gray"
}
type SessionTabs = {
active?: string
all: string[]
}
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
reviewMode?: ReviewChangeMode
reviewFile?: string
pendingMessage?: string
pendingMessageAt?: number
todoCollapsed?: boolean
@@ -78,6 +77,7 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export type ReviewDiffStyle = "unified" | "split"
export type ReviewChangeMode = "git" | "branch" | "turn"
export type ReviewPanelSource = "context-button" | "other"
export type LayoutRoute =
@@ -86,14 +86,6 @@ export type LayoutRoute =
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
| { type: "session"; sessionId: string; server?: ServerConnection.Key }
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
const all = current?.all ?? []
if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab }
if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab }
if (!all.includes(tab)) return { all: [...all, tab], active: tab }
return { all, active: tab }
}
const sessionPath = (key: string) => {
const dir = SessionStateKey.route(key).split("/")[0]
if (!dir) return
@@ -307,6 +299,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
)
const [ephemeral, setEphemeral] = createStore({
reviewPanelSource: "other" as ReviewPanelSource,
sessionTabPreview: {} as Record<string, string | undefined>,
})
const MAX_SESSION_KEYS = 50
@@ -365,6 +358,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
scroll.drop(drop)
dropSessionState(drop)
setEphemeral(
"sessionTabPreview",
produce((draft) => {
for (const key of drop) delete draft[key]
}),
)
for (const key of drop) {
usage.used.delete(key)
@@ -493,7 +492,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const root = rootFor(project.worktree)
if (root === project.worktree) continue
server.projects.close(project.worktree)
server.projects.remove(project.worktree)
if (!seen.has(root)) {
server.projects.open(root)
@@ -613,6 +612,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
projects: {
list,
recentlyClosed: createMemo(() => {
const known = new Set(serverSync().data.project.map((project) => pathKey(project.worktree)))
return server.projects
.recentlyClosed()
.filter((worktree) => known.has(pathKey(worktree)))
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
.map((worktree) => enrich({ worktree, expanded: false }))
}),
open(directory: string) {
const root = rootFor(directory)
if (server.projects.list().find((x) => x.worktree === root)) return
@@ -782,6 +789,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
view(sessionKey: string | Accessor<string>) {
const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const reviewMode = createMemo(() => {
const mode = s().reviewMode
if (mode === "git" || mode === "branch" || mode === "turn") return mode
})
const reviewFile = createMemo(() => {
const file = s().reviewFile
if (typeof file === "string") return file
})
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
@@ -865,6 +880,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
review: {
mode: reviewMode,
setMode(mode: ReviewChangeMode) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, { scroll: {}, reviewMode: mode })
prune(session)
return
}
if (current.reviewMode === mode) return
setStore("sessionView", session, "reviewMode", mode)
prune(session)
},
file: reviewFile,
setFile(file: string) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, { scroll: {}, reviewFile: file })
prune(session)
return
}
if (current.reviewFile === file) return
setStore("sessionView", session, "reviewFile", file)
prune(session)
},
open: createMemo(() => s().reviewOpen ?? []),
setOpen(open: string[]) {
const session = key()
@@ -936,10 +977,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
const apply = (session: string, next: ReturnType<typeof openSessionTab>) => {
batch(() => {
setStore("sessionTabs", session, next.tabs)
setEphemeral("sessionTabPreview", session, next.preview)
})
}
return {
tabs,
active: createMemo(() => tabs().active),
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
preview: createMemo(() => ephemeral.sessionTabPreview[key()]),
setActive(tab: string | undefined) {
const session = key()
const next = tab ? normalize(tab) : tab
@@ -952,40 +1000,44 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setAll(all: string[]) {
const session = key()
const next = normalizeAll(all).filter((tab) => tab !== "review")
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: next, active: undefined })
} else {
setStore("sessionTabs", session, "all", next)
}
batch(() => {
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: next, active: undefined })
} else {
setStore("sessionTabs", session, "all", next)
}
const preview = ephemeral.sessionTabPreview[session]
if (preview && !next.includes(preview)) setEphemeral("sessionTabPreview", session, undefined)
})
},
async open(tab: string) {
const session = key()
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
setStore("sessionTabs", session, next)
apply(
session,
openSessionTab(
{ tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
normalize(tab),
),
)
},
previewTab(tab: string) {
const session = key()
apply(
session,
previewSessionTab(
{ tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
normalize(tab),
),
)
},
close(tab: string) {
const session = key()
const current = store.sessionTabs[session]
if (!current) return
if (tab === "review") {
if (current.active !== tab) return
setStore("sessionTabs", session, "active", current.all[0])
return
}
const all = current.all.filter((x) => x !== tab)
if (current.active !== tab) {
setStore("sessionTabs", session, "all", all)
return
}
const index = current.all.findIndex((f) => f === tab)
const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
batch(() => {
setStore("sessionTabs", session, "all", all)
setStore("sessionTabs", session, "active", next)
})
apply(
session,
closeSessionTab({ tabs: current, preview: ephemeral.sessionTabPreview[session] }, normalize(tab)),
)
},
move(tab: string, to: number) {
const session = key()
@@ -15,11 +15,13 @@ const session = (id: string, parentID?: string): Session => ({
})
type UserMessage = Extract<Message, { role: "user" }>
type AssistantMessage = Extract<Message, { role: "assistant" }>
type TextPart = Extract<Part, { type: "text" }>
type MessageResponse = {
data: { info: Message; parts: Part[] }[]
response: { headers: Headers }
}
type SingleMessageResponse = { data: MessageResponse["data"][number] }
const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage => ({
id,
@@ -31,6 +33,22 @@ const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage
...input,
})
const assistantMessage = (id: string, parentID: string, input: Partial<AssistantMessage> = {}): AssistantMessage => ({
id,
sessionID: "child",
role: "assistant",
time: { created: Number(id.at(-1)), completed: Number(id.at(-1)) },
parentID,
modelID: "model",
providerID: "provider",
mode: "build",
agent: "build",
path: { cwd: "/repo", root: "/repo" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
...input,
})
const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({
id: "part",
sessionID: "child",
@@ -45,6 +63,8 @@ const response = (data: MessageResponse["data"] = [], cursor?: string): MessageR
response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
})
const singleResponse = (info: Message, parts: Part[] = []): SingleMessageResponse => ({ data: { info, parts } })
const deferredResponse = () => Promise.withResolvers<MessageResponse>()
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
@@ -71,6 +91,40 @@ function messageClient(...responses: Array<MessageResponse | Promise<MessageResp
})
}
function rootMessageClient(
pages: Array<MessageResponse | Promise<MessageResponse>>,
roots: Array<SingleMessageResponse | Promise<SingleMessageResponse>>,
) {
let pageIndex = 0
let rootIndex = 0
const requests: unknown[] = []
const rootRequests: unknown[] = []
const rootWaiting = new Map<number, () => void>()
const client = {
session: {
get: async () => ({ data: session("child", "root") }),
messages: (input: unknown) => {
requests.push(input)
return pages[pageIndex++]
},
message: (input: unknown) => {
rootRequests.push(input)
rootWaiting.get(rootRequests.length)?.()
rootWaiting.delete(rootRequests.length)
return roots[rootIndex++]
},
},
} as unknown as OpencodeClient
return Object.assign(client, {
requests,
rootRequests,
rootRequested(count: number) {
if (rootRequests.length >= count) return Promise.resolve()
return new Promise<void>((resolve) => rootWaiting.set(count, resolve))
},
})
}
const retryImmediately: typeof retry = async (task, options = {}) => {
const attempts = options.attempts ?? 3
for (let attempt = 0; ; attempt++) {
@@ -124,6 +178,238 @@ describe("server session", () => {
expect(ctx.store.data.message.root).toEqual([])
})
test("backfills an assistant-only initial page through its user root", async () => {
const user = userMessage("message-1")
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient(
[
response(
assistants.map((info) => ({ info, parts: [] })),
"older",
),
],
[singleResponse(user)],
)
const store = createServerSession(client)
await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(true)
})
test("does not let an optimistic user suppress initial root backfill", async () => {
const user = userMessage("message-1")
const part = textPart(user.id)
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient(
[
response(
assistants.map((info) => ({ info, parts: [] })),
"older",
),
],
[singleResponse(user)],
)
const store = createServerSession(client)
store.optimistic.add({ sessionID: "child", message: user, parts: [part] })
await store.sync("child")
store.optimistic.remove({ sessionID: "child", messageID: user.id })
expect(client.requests).toHaveLength(1)
expect(client.rootRequests).toHaveLength(1)
expect(store.data.message.child).toEqual([user, ...assistants])
})
test("backfills the parent of fetched assistants when another user is cached", async () => {
const unrelated = userMessage("message-0", { time: { created: 0 } })
const user = userMessage("message-1")
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient(
[
response([{ info: unrelated, parts: [] }]),
response(
assistants.map((info) => ({ info, parts: [] })),
"older",
),
],
[singleResponse(user)],
)
const store = createServerSession(client)
await store.sync("child")
await store.sync("child", { force: true })
expect(client.requests).toHaveLength(2)
expect(client.rootRequests).toHaveLength(1)
expect(store.data.message.child).toEqual([unrelated, user, ...assistants])
})
test("preserves cached history between an injected parent and the page boundary", async () => {
const user = userMessage("message-1")
const cached = userMessage("message-3", { time: { created: 3 } })
const assistant = assistantMessage("message-4", user.id)
const client = rootMessageClient(
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }], "older")],
[singleResponse(user)],
)
const store = createServerSession(client)
await store.sync("child")
await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([user, cached, assistant])
})
test("refreshes a cached parent omitted by an assistant-only replacement page", async () => {
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
const stalePart = textPart(stale.id, { text: "stale" })
const freshPart = { ...stalePart, text: "fresh" }
const assistant = assistantMessage("message-2", stale.id)
const client = rootMessageClient(
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }], "older")],
[singleResponse(fresh, [freshPart])],
)
const store = createServerSession(client)
await store.sync("child")
await store.sync("child", { force: true })
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
expect(store.data.message.child).toEqual([fresh, assistant])
expect(store.data.part[stale.id]).toEqual([freshPart])
})
test("refreshes a confirmed optimistic parent while preserving pending parts", async () => {
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" })
const refreshed = { ...confirmed, text: "fresh" }
const pending = textPart(stale.id, { id: "pending", text: "pending" })
const assistant = assistantMessage("message-2", stale.id)
const client = rootMessageClient(
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }], "older")],
[singleResponse(fresh, [refreshed])],
)
const store = createServerSession(client)
store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] })
await store.sync("child")
await store.sync("child", { force: true })
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
expect(store.data.message.child).toEqual([fresh, assistant])
expect(store.data.part[stale.id]).toEqual([refreshed, pending])
})
test("uses a parent received by SSE during the replacement load", async () => {
const pending = deferredResponse()
const user = userMessage("message-1")
const assistant = assistantMessage("message-2", user.id)
const client = rootMessageClient([pending.promise], [])
const store = createServerSession(client)
const loading = store.sync("child")
store.apply({ type: "message.updated", properties: { info: user } })
pending.resolve(response([{ info: assistant, parts: [] }], "older"))
await loading
expect(client.rootRequests).toEqual([])
expect(store.data.message.child).toEqual([user, assistant])
})
test("uses a successful retry over events received by a failed backfill attempt", async () => {
const failed = deferredResponse()
const user = userMessage("message-1")
const live = { ...user, agent: "stale" }
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient(
[
response(
assistants.map((info) => ({ info, parts: [] })),
"older",
),
],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
)
const store = createServerSession(client, { retry: retryImmediately })
const loading = store.sync("child")
await client.rootRequested(1)
store.apply({ type: "message.updated", properties: { info: live } })
failed.reject(new Error("retry"))
await loading
expect(client.requests).toHaveLength(1)
expect(client.rootRequests).toHaveLength(2)
expect(store.data.message.child).toEqual([user, ...assistants])
})
test("preserves newer-page events across a failed parent retry", async () => {
const failed = deferredResponse()
const user = userMessage("message-1")
const assistant = assistantMessage("message-2", user.id)
const live = { ...assistant, cost: 1 }
const client = rootMessageClient(
[response([{ info: assistant, parts: [] }], "older")],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
)
const store = createServerSession(client, { retry: retryImmediately })
const loading = store.sync("child")
await client.rootRequested(1)
store.apply({ type: "message.updated", properties: { info: live } })
failed.reject(new Error("retry"))
await loading
expect(store.data.message.child).toEqual([user, live])
})
test("preserves unrelated message events across a failed parent retry", async () => {
const failed = deferredResponse()
const user = userMessage("message-1")
const assistant = assistantMessage("message-2", user.id)
const live = userMessage("message-4", { time: { created: 4 } })
const client = rootMessageClient(
[response([{ info: assistant, parts: [] }], "older")],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
)
const store = createServerSession(client, { retry: retryImmediately })
const loading = store.sync("child")
await client.rootRequested(1)
store.apply({ type: "message.updated", properties: { info: live } })
failed.reject(new Error("retry"))
await loading
expect(store.data.message.child).toEqual([user, assistant, live])
})
test("preserves newer-page part events across a failed parent retry", async () => {
const failed = deferredResponse()
const user = userMessage("message-1")
const assistant = assistantMessage("message-2", user.id)
const stale = textPart(assistant.id, { text: "stale" })
const live = { ...stale, text: "live" }
const client = rootMessageClient(
[response([{ info: assistant, parts: [stale] }], "older")],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
)
const store = createServerSession(client, { retry: retryImmediately })
const loading = store.sync("child")
await client.rootRequested(1)
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
failed.reject(new Error("retry"))
await loading
expect(store.data.part[assistant.id]).toEqual([live])
})
test("merges live events into the initial page", async () => {
const pending = deferredResponse()
const user = userMessage("message-1")
@@ -905,6 +1191,26 @@ describe("server session", () => {
expect(store.data.message.child).toEqual([latest])
})
test("does not scan cached messages for user roots during history prepend", async () => {
const guard = { active: false }
const latest = new Proxy(userMessage("message-2", { time: { created: 2 } }), {
get(target, property, receiver) {
if (guard.active && property === "role") throw new Error("cached role accessed")
return Reflect.get(target, property, receiver)
},
})
const older = userMessage("message-1")
const store = createServerSession(
messageClient(response([{ info: latest, parts: [] }], "older"), response([{ info: older, parts: [] }])),
)
await store.sync("child")
guard.active = true
await store.history.loadMore("child")
expect(store.data.message.child).toEqual([older, latest])
})
test("preserves loaded history during an incomplete refresh", async () => {
const older = userMessage("message-1")
const latest = userMessage("message-2", { time: { created: 2 } })
+109 -30
View File
@@ -54,6 +54,11 @@ type MessageLoadState = {
clearedMessageParts: Set<string>
}
type MessageLoadBaseline = Pick<
MessageLoadState,
"touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts"
>
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
const session = [...page.session]
@@ -347,7 +352,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
load.touchedParts.set(messageID, new Set([partID]))
}
const resetMessageLoad = (sessionID: string, load: MessageLoadState) => {
const resetMessageLoad = (sessionID: string, load: MessageLoadState, baseline?: MessageLoadBaseline) => {
load.touchedMessages.clear()
load.retainedMessages.clear()
load.touchedParts.clear()
@@ -380,8 +385,27 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched)
}
baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID))
baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID))
baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID))
baseline?.touchedParts.forEach((parts, messageID) => {
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched)
})
}
const messageLoadBaseline = (load: MessageLoadState, exclude: string): MessageLoadBaseline => ({
touchedMessages: new Set([...load.touchedMessages].filter((messageID) => messageID !== exclude)),
retainedMessages: new Set([...load.retainedMessages].filter((messageID) => messageID !== exclude)),
touchedParts: new Map(
[...load.touchedParts]
.filter(([messageID]) => messageID !== exclude)
.map(([messageID, parts]) => [messageID, new Set(parts)]),
),
clearedMessageParts: new Set([...load.clearedMessageParts].filter((messageID) => messageID !== exclude)),
})
const evict = (sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
const evicted = new Set(sessionIDs)
@@ -460,6 +484,18 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
}
}
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
return client.session.message({ sessionID, messageID })
})
if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`)
return {
message: cleanMessage(response.data.info),
parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
}
}
const replaceMessages = (sessionID: string, messages: Message[]) => {
const messageIDs = new Set(messages.map((message) => message.id))
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
@@ -578,36 +614,79 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
messageLoads.set(sessionID, load)
setMeta("loading", sessionID, true)
let applied = false
await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
.then((page) => {
if (generations.get(sessionID) !== active) return
const first = page.session.reduce<Message | undefined>(
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
undefined,
)
const preserveUnfetched =
mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
applyMessagePage(
sessionID,
page,
messageLoads.get(sessionID) === load ? load : undefined,
preserveUnfetched,
mode !== "prepend",
)
applied = true
})
.finally(() => {
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
for (const messageID of load.orphanParents) {
if (!orphanParts.get(sessionID)?.has(messageID)) continue
setData(produce((draft) => deleteMessageParts(draft, messageID)))
orphanParts.get(sessionID)?.delete(messageID)
}
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
try {
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
const first = page.session.reduce<Message | undefined>(
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
undefined,
)
if (generations.get(sessionID) !== active) return
const parents = [] as Awaited<ReturnType<typeof fetchMessage>>[]
if (mode !== "prepend") {
const users = new Set([
...page.session.filter((message) => message.role === "user").map((message) => message.id),
...(data.message[sessionID] ?? [])
.filter((message) => {
if (message.role !== "user") return false
const item = optimistic.get(sessionID)?.get(message.id)
return load.touchedMessages.has(message.id) && (!item || item.confirmedMessage === true)
})
.map((message) => message.id),
])
const parentIDs = [
...new Set(
page.session.flatMap((message) =>
message.role === "assistant" && !users.has(message.parentID) ? [message.parentID] : [],
),
),
]
for (const parentID of parentIDs) {
if (generations.get(sessionID) !== active) break
const parent = await fetchMessage(sessionID, parentID, () =>
resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)),
)
if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`)
parents.push(parent)
}
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
})
}
if (generations.get(sessionID) !== active) return
const result =
mode === "prepend"
? page
: {
...page,
session: merge(
page.session,
parents.map((parent) => parent.message),
),
part: merge(
page.part,
parents.map((parent) => ({ id: parent.message.id, part: parent.parts })),
),
}
const preserveUnfetched =
mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
applyMessagePage(
sessionID,
result,
messageLoads.get(sessionID) === load ? load : undefined,
preserveUnfetched,
mode !== "prepend",
)
applied = true
} finally {
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
for (const messageID of load.orphanParents) {
if (!orphanParts.get(sessionID)?.has(messageID)) continue
setData(produce((draft) => deleteMessageParts(draft, messageID)))
orphanParts.get(sessionID)?.delete(messageID)
}
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
}
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
}
}
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
+83 -1
View File
@@ -99,7 +99,7 @@ describe("createServerProjects", () => {
test("keeps active and explicit server buckets in one reactive store", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {} })
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const active = createServerProjects({ scope, store, setStore })
const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
@@ -115,6 +115,88 @@ describe("createServerProjects", () => {
dispose()
})
})
test("tracks recently closed projects and drops them when reopened", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
projects.open("/a")
projects.open("/b")
projects.close("/a")
expect(projects.recentlyClosed()).toEqual(["/a"])
projects.close("/b")
expect(projects.recentlyClosed()).toEqual(["/b", "/a"])
projects.open("/a")
expect(projects.recentlyClosed()).toEqual(["/b"])
expect(projects.list()).toEqual([{ worktree: "/a", expanded: true }])
dispose()
})
})
test("remove drops a project without recording it as recently closed", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
projects.open("/repo/subdir")
projects.remove("/repo/subdir")
expect(projects.list()).toEqual([])
expect(projects.recentlyClosed()).toEqual([])
dispose()
})
})
test("retains recently closed history beyond the visible display limit", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
// Closing 6 projects keeps all 6 in the store even though only 5 are displayed;
// this prevents display-filtered entries from evicting still-visible ones.
for (const dir of ["/1", "/2", "/3", "/4", "/5", "/6"]) {
projects.open(dir)
projects.close(dir)
}
expect(projects.recentlyClosed()).toEqual(["/6", "/5", "/4", "/3", "/2", "/1"])
dispose()
})
})
test("caps recently closed history at the store limit", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
for (let i = 1; i <= 20; i++) {
projects.open(`/p${i}`)
projects.close(`/p${i}`)
}
expect(projects.recentlyClosed()).toHaveLength(16)
expect(projects.recentlyClosed()[0]).toBe("/p20")
expect(projects.recentlyClosed().at(-1)).toBe("/p5")
dispose()
})
})
test("dedupes recently closed entries by normalized path", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
projects.close("/repo")
projects.close("/repo/")
expect(projects.recentlyClosed()).toEqual(["/repo/"])
dispose()
})
})
})
describe("migrateCanonicalLocalServerState", () => {
+44 -6
View File
@@ -2,12 +2,23 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createMemo } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key"
import { ServerScope } from "@/utils/server-scope"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
type ServerProjectState = { projects: Record<string, StoredProject[]>; lastProject: Record<string, string> }
type ServerProjectState = {
projects: Record<string, StoredProject[]>
lastProject: Record<string, string>
recentlyClosed: Record<string, string[]>
}
const HEALTH_POLL_INTERVAL_MS = 10_000
// The store retains more history than is displayed. Consumers filter recently closed entries
// against the live project list (dropping deleted projects) and then cap the visible count via
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
// filtered out do not evict still-visible ones from the persisted store.
const RECENTLY_CLOSED_HISTORY_LIMIT = 16
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
export function normalizeServerUrl(input: string) {
const trimmed = input.trim()
@@ -72,19 +83,42 @@ export function createServerProjects<T extends ServerProjectState>(input: {
}) {
const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
const current = () => input.store.projects[input.scope()] ?? []
const currentClosed = () => input.store.recentlyClosed?.[input.scope()] ?? []
const remove = (directory: string) => {
setStore(
"projects",
input.scope(),
current().filter((project) => project.worktree !== directory),
)
}
return {
list: current,
recentlyClosed: currentClosed,
remove,
open(directory: string) {
const scope = input.scope()
const key = pathKey(directory)
const closed = currentClosed()
if (closed.some((worktree) => pathKey(worktree) === key)) {
setStore(
"recentlyClosed",
scope,
closed.filter((worktree) => pathKey(worktree) !== key),
)
}
if (current().some((project) => project.worktree === directory)) return
setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
},
// User-initiated close: removes the project and records it in recently closed.
// Internal, non-user removals (e.g. sandbox/worktree normalization) should use remove().
close(directory: string) {
setStore(
"projects",
input.scope(),
current().filter((project) => project.worktree !== directory),
remove(directory)
const key = pathKey(directory)
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
0,
RECENTLY_CLOSED_HISTORY_LIMIT,
)
setStore("recentlyClosed", input.scope(), closed)
},
expand(directory: string) {
const index = current().findIndex((project) => project.worktree === directory)
@@ -235,6 +269,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
list: [] as StoredServer[],
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
recentlyClosed: {} as Record<string, string[]>,
}),
)
@@ -277,7 +312,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
})
}
const isReady = createMemo(() => ready() && !!state.active)
const isReady = Object.assign(
createMemo(() => ready() && !!state.active),
{ promise: ready.promise },
)
const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
const projects = createServerProjects({ scope, store, setStore })
+81
View File
@@ -1,6 +1,15 @@
import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import type { SessionTab, Tab } from "./tabs"
import type { ServerConnection } from "./server"
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
function sessionTab(sessionId: string): SessionTab {
return { type: "session", server, sessionId }
}
describe("tab memory", () => {
test("keeps state until its tab is removed", () => {
@@ -22,3 +31,75 @@ describe("tab memory", () => {
})
})
})
describe("closed tab stack", () => {
test("records session tabs with their index", () => {
const stack = pushClosedTab([], sessionTab("a"), 2)
expect(stack).toEqual([{ tab: sessionTab("a"), index: 2 }])
})
test("ignores draft tabs", () => {
const draft: Tab = { type: "draft", draftID: "d1", server, directory: "/tmp" }
expect(pushClosedTab([], draft, 0)).toEqual([])
})
test("caps the stack size", () => {
const stack = Array.from({ length: 30 }, (_, i) => i).reduce<ClosedTab[]>(
(acc, i) => pushClosedTab(acc, sessionTab(`s${i}`), i),
[],
)
expect(stack).toHaveLength(25)
expect(stack[0]?.tab.sessionId).toBe("s5")
expect(stack.at(-1)?.tab.sessionId).toBe("s29")
})
test("pops the most recently closed tab", () => {
const stack = [
{ tab: sessionTab("a"), index: 0 },
{ tab: sessionTab("b"), index: 1 },
]
const result = takeClosedTab(stack, [])
expect(result.entry?.tab.sessionId).toBe("b")
expect(result.stack).toEqual([{ tab: sessionTab("a"), index: 0 }])
})
test("skips entries whose tab is already open", () => {
const stack = [
{ tab: sessionTab("a"), index: 0 },
{ tab: sessionTab("b"), index: 1 },
]
const result = takeClosedTab(stack, [sessionTab("b")])
expect(result.entry?.tab.sessionId).toBe("a")
expect(result.stack).toEqual([])
})
test("returns no entry when everything is open or empty", () => {
expect(takeClosedTab([], []).entry).toBeUndefined()
const result = takeClosedTab([{ tab: sessionTab("a"), index: 0 }], [sessionTab("a")])
expect(result.entry).toBeUndefined()
expect(result.stack).toEqual([])
})
test("purges removed sessions", () => {
const stack = [
{ tab: sessionTab("a"), index: 0 },
{ tab: sessionTab("b"), index: 1 },
]
expect(removeClosedTabs(stack, server, ["a"])).toEqual([{ tab: sessionTab("b"), index: 1 }])
})
test("does not navigate when a background tab closes", () => {
const tabs = [sessionTab("a"), sessionTab("b"), sessionTab("c")]
expect(nextTabAfterClose(tabs, 1, false)).toBeUndefined()
expect(nextTabAfterClose(tabs, 1, true)).toEqual(sessionTab("c"))
expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull()
})
})
+91 -5
View File
@@ -10,6 +10,7 @@ import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
export type SessionTab = {
type: "session"
@@ -27,6 +28,11 @@ export type DraftTab = {
export type Tab = SessionTab | DraftTab
export type TabInfo = {
title?: string
directory?: string
}
type RecentTab = {
key?: string
}
@@ -63,6 +69,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
createStore<Tab[]>([]),
)
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
const [info, setInfo] = persisted(Persist.window("tabs.info"), createStore<Record<string, TabInfo>>({}))
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
const params = useParams()
const navigate = useNavigate()
@@ -87,10 +95,28 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
})
}
const updateClosed = (update: (stack: ClosedTab[]) => ClosedTab[]) => {
const apply = () => setClosed((stack) => update(stack))
if (closedReady()) {
apply()
return
}
void closedReady.promise?.then(apply)
}
const removeDraftPersisted = (draftID: string) => {
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
}
const removeInfo = (key: string) => {
if (!info[key]) return
setInfo(
produce((draft) => {
delete draft[key]
}),
)
}
onCleanup(memory.dispose)
createEffect(() => {
@@ -99,11 +125,26 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const next = store.filter((tab) => servers.has(tab.server))
if (next.length !== store.length) {
for (const tab of store) {
if (!servers.has(tab.server)) memory.remove(tabKey(tab))
if (!servers.has(tab.server)) {
const key = tabKey(tab)
memory.remove(key)
removeInfo(key)
}
}
setStore(() => next)
}
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
const keys = new Set(next.map(tabKey))
for (const key of Object.keys(info)) {
if (!keys.has(key)) removeInfo(key)
}
})
createEffect(() => {
if (!closedReady()) return
const servers = new Set(server.list.map(ServerConnection.key))
const next = closed.filter((entry) => servers.has(entry.tab.server))
if (next.length !== closed.length) setClosed(() => next)
})
const navigateTab = (tab: Tab) => {
@@ -117,7 +158,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (!tab) return
const key = tabKey(tab)
const draftID = tab.type === "draft" ? tab.draftID : undefined
const nextTab = store[index + 1] ?? store[index - 1]
const nextTab = nextTabAfterClose(store, index, recentKey() === key && location.pathname !== "/")
closing.add(key)
void startTransition(() => {
setStore(
@@ -125,11 +166,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
tabs.splice(index, 1)
}),
)
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
if (nextTab === null) {
setRecentKey(undefined)
navigate("/")
}
if (nextTab) navigateTab(nextTab)
else navigate("/")
}).finally(() => closing.delete(key))
memory.remove(key)
removeInfo(key)
if (draftID) removeDraftPersisted(draftID)
}
@@ -201,23 +245,57 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
removeDraftPersisted(draftID)
},
removeTab,
// User-initiated close: records the tab so it can be reopened.
// Cleanup paths (missing sessions, archive, server removal) go through
// removeTab and friends directly and are not recorded.
closeTab(index: number) {
const tab = store[index]
if (!tab) return
if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index))
removeTab(index)
},
reopenClosedTab() {
if (!closedReady()) {
void closedReady.promise?.then(() => actions.reopenClosedTab())
return
}
const result = takeClosedTab(closed, store)
if (result.stack.length === closed.length) return
setClosed(() => result.stack)
const entry = result.entry
if (!entry) return
const index = Math.min(entry.index, store.length)
void startTransition(() => {
setStore(
produce((tabs) => {
if (tabs.some((item) => tabKey(item) === tabKey(entry.tab))) return
tabs.splice(index, 0, entry.tab)
}),
)
navigateTab(entry.tab)
})
},
removeSessionTab(input: Omit<SessionTab, "type">) {
updateClosed((stack) => removeClosedTabs(stack, input.server, [input.sessionId]))
const index = store.findIndex(
(tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId,
)
if (index !== -1) removeTab(index)
},
removeServer(key: ServerConnection.Key) {
updateClosed((stack) => stack.filter((entry) => entry.tab.server !== key))
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
const removed = store.filter((tab) => tab.server === key).map(tabKey)
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
for (const key of removed) memory.remove(key)
for (const key of removed) removeInfo(key)
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
for (const draftID of drafts) removeDraftPersisted(draftID)
if (server.key === key) navigate("/")
},
removeSessions: (input: SessionTabsRemovedDetail) => {
const targetServer = input.server ?? server.key
updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs))
const removed = store
.filter(
(tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId),
@@ -265,6 +343,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
})
for (const key of removed) memory.remove(key)
for (const key of removed) removeInfo(key)
},
rememberSessionInfo(tab: SessionTab, session: Session) {
const key = tabKey(tab)
const next = { title: session.title, directory: session.directory }
const current = info[key]
if (current?.title === next.title && current.directory === next.directory) return
setInfo(key, next)
},
select: navigateTab,
remember(tab: Tab) {
@@ -289,6 +375,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
}
return { ...actions, store, ready, recentReady }
return { ...actions, store, info, ready, recentReady }
},
})
+148 -2
View File
@@ -21,6 +21,9 @@ export const dict = {
"theme.scheme.dark": "داكن",
"command.sidebar.toggle": "تبديل الشريط الجانبي",
"command.project.open": "فتح مشروع",
"command.project.previous": "المشروع السابق",
"command.project.next": "المشروع التالي",
"command.project.index": "التبديل إلى المشروع {{index}}",
"command.provider.connect": "اتصال بموفر",
"command.server.switch": "تبديل الخادم",
"command.settings.open": "فتح الإعدادات",
@@ -39,6 +42,7 @@ export const dict = {
"command.session.new": "جلسة جديدة",
"command.file.open": "فتح ملف",
"command.tab.close": "إغلاق علامة التبويب",
"command.tab.reopenClosed": "إعادة فتح علامة التبويب المغلقة",
"command.context.addSelection": "إضافة التحديد إلى السياق",
"command.context.addSelection.description": "إضافة الأسطر المحددة من الملف الحالي",
"command.input.focus": "التركيز على حقل الإدخال",
@@ -193,6 +197,11 @@ export const dict = {
"model.input.video": "فيديو",
"model.input.pdf": "pdf",
"model.tooltip.allows": "يسمح: {{inputs}}",
"model.tooltip.context.label": "السياق",
"model.tooltip.inputs": "المدخلات",
"model.tooltip.model": "النموذج",
"model.tooltip.provider": "الموفر",
"model.tooltip.reasoning": "الاستنتاج",
"model.tooltip.reasoning.allowed": "يسمح بالاستنتاج",
"model.tooltip.reasoning.none": "بدون استنتاج",
"model.tooltip.context": "حد السياق {{limit}}",
@@ -202,6 +211,7 @@ export const dict = {
"common.loading": "جارٍ التحميل",
"common.loading.ellipsis": "...",
"common.cancel": "إلغاء",
"common.clear": "مسح",
"common.connect": "اتصال",
"common.disconnect": "قطع الاتصال",
"common.continue": "إرسال",
@@ -218,6 +228,8 @@ export const dict = {
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc للخروج",
"session.child.promptDisabled": "لا يمكن إرسال موجّهات إلى جلسات الوكلاء الفرعيين.",
"session.child.backToParent": "العودة إلى الجلسة الرئيسية.",
"prompt.example.1": "إصلاح TODO في قاعدة التعليمات البرمجية",
"prompt.example.2": "ما هو المكدس التقني لهذا المشروع؟",
"prompt.example.3": "إصلاح الاختبارات المعطلة",
@@ -314,6 +326,74 @@ export const dict = {
"dialog.server.menu.delete": "حذف",
"dialog.server.current": "الخادم الحالي",
"dialog.server.status.default": "افتراضي",
"wsl.server.add": "إضافة خادم WSL",
"wsl.server.addShort": "إضافة WSL",
"wsl.server.label": "WSL",
"wsl.server.menu.label": "خادم WSL",
"wsl.server.retryStart": "إعادة محاولة التشغيل",
"wsl.server.updating": "جارٍ التحديث...",
"wsl.onboarding.step.distro": "اختر توزيعة",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "جارٍ التحقق من WSL...",
"wsl.onboarding.restartRequired": "يحتاج Windows إلى إعادة التشغيل لإكمال تثبيت WSL.",
"wsl.onboarding.ready": "WSL جاهز.",
"wsl.onboarding.required": "يلزم WSL للمتابعة.",
"wsl.onboarding.checkingDistros": "جارٍ التحقق من التوزيعات...",
"wsl.onboarding.installingDistro": "جارٍ تثبيت {{distro}}...",
"wsl.onboarding.checkingDistro": "جارٍ التحقق من {{distro}}...",
"wsl.onboarding.listingDistros": "جارٍ سرد التوزيعات...",
"wsl.onboarding.distroReady": "{{distro}} جاهزة.",
"wsl.onboarding.distroNotInstalled": "لم يتم تثبيت {{distro}} بعد.",
"wsl.onboarding.openDistroOnce": "افتح {{distro}} مرة واحدة لإكمال الإعداد.",
"wsl.onboarding.finishingDistro": "جارٍ إكمال إعداد {{distro}}.",
"wsl.onboarding.pickDistro": "اختر توزيعة أو ثبّت واحدة أدناه.",
"wsl.onboarding.checkingOpencode": "جارٍ التحقق من OpenCode...",
"wsl.onboarding.checkingOpencodeIn": "جارٍ التحقق من OpenCode في {{distro}}...",
"wsl.onboarding.updatingOpencode": "جارٍ تحديث OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "جارٍ تحديث OpenCode في {{distro}}...",
"wsl.onboarding.updateOpencodeIn": "حدّث OpenCode في {{distro}}.",
"wsl.onboarding.updateOpencode": "تحديث OpenCode",
"wsl.onboarding.opencodeReadyIn": "OpenCode جاهز في {{distro}}.",
"wsl.onboarding.opencodeReady": "OpenCode جاهز.",
"wsl.onboarding.installOpencodeIn": "ثبّت OpenCode في {{distro}}.",
"wsl.onboarding.installOpencode": "تثبيت OpenCode",
"wsl.onboarding.chooseDistroFirst": "اختر توزيعة أولاً.",
"wsl.onboarding.loadFailed": "فشل تحميل حالة WSL.",
"wsl.onboarding.loading": "جارٍ التحميل...",
"wsl.onboarding.installedDistros": "التوزيعات المثبتة",
"wsl.onboarding.checkAgain": "تحقق مجددًا",
"wsl.onboarding.distroStatus.ready": "جاهزة",
"wsl.onboarding.distroStatus.checking": "جارٍ التحقق...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode غير مثبت",
"wsl.onboarding.distroStatus.missingTools": "bash و curl مفقودان",
"wsl.onboarding.distroStatus.unsupported": "غير مدعومة · استخدم WSL 2",
"wsl.onboarding.needAnotherDistro": "هل تحتاج إلى توزيعة أخرى؟",
"wsl.onboarding.needAnotherDistroHint": "ثبّت توزيعة Linux من كتالوج WSL",
"wsl.onboarding.wslNotInstalled.title": "WSL غير مثبت",
"wsl.onboarding.wslNotInstalled.description":
"يلزم WSL (نظام Windows الفرعي لـ Linux) قبل أن يتمكن OpenCode من إضافة خادم WSL",
"wsl.onboarding.wslUnavailable.title": "WSL غير متاح",
"wsl.onboarding.wslUnavailable.description": "تعذر على OpenCode التحقق من WSL على هذا الجهاز.",
"wsl.onboarding.installWsl": "تثبيت WSL",
"wsl.onboarding.windowsRestartRequired": "أعد تشغيل Windows لإكمال تثبيت WSL، ثم أعد فتح OpenCode.",
"wsl.onboarding.next": "التالي",
"wsl.onboarding.refresh": "تحديث",
"wsl.onboarding.allDistrosAdded": "تمت إضافة جميع التوزيعات المثبتة بالفعل.",
"wsl.onboarding.noDistros": "لم يتم اكتشاف أي توزيعات بعد.",
"wsl.onboarding.install": "تثبيت",
"wsl.onboarding.installing": "جارٍ التثبيت...",
"wsl.onboarding.installDistro": "تثبيت توزيعة",
"wsl.onboarding.searchDistros": "البحث عن توزيعات",
"wsl.onboarding.wsl2Required": "يلزم WSL 2.",
"wsl.onboarding.toolsRequired": "تحتاج هذه التوزيعة إلى bash و curl.",
"wsl.onboarding.openTerminal": "فتح المحطة الطرفية",
"wsl.onboarding.path": "المسار: {{path}}",
"wsl.onboarding.notFound": "غير موجود",
"wsl.onboarding.version": "الإصدار: {{version}}",
"wsl.onboarding.unknown": "غير معروف",
"wsl.onboarding.desktopVersion": "سطح المكتب {{version}}",
"wsl.onboarding.versionMismatch": "لا يتطابق الإصدار المثبت مع إصدار تطبيق سطح المكتب.",
"wsl.onboarding.adding": "جارٍ الإضافة...",
"dialog.project.edit.title": "تحرير المشروع",
"dialog.project.edit.name": "الاسم",
"dialog.project.edit.icon": "أيقونة",
@@ -370,6 +450,7 @@ export const dict = {
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "لغة",
@@ -406,10 +487,12 @@ export const dict = {
"toast.update.action.notYet": "ليس الآن",
"error.page.title": "حدث خطأ ما",
"error.page.description": "حدث خطأ أثناء تحميل التطبيق.",
"error.page.description.localServerStartup": "حدث خطأ أثناء بدء تشغيل الخادم المحلي.",
"error.page.details.label": "تفاصيل الخطأ",
"error.page.action.restart": "إعادة تشغيل",
"error.page.action.report": "الإبلاغ عن الخطأ",
"error.page.action.reported": "تم الإبلاغ عن الخطأ",
"error.page.action.exportLogs": "تصدير السجلات",
"error.page.action.checking": "جارٍ التحقق...",
"error.page.action.checkUpdates": "التحقق من وجود تحديثات",
"error.page.action.updateTo": "تحديث إلى {{version}}",
@@ -450,16 +533,38 @@ export const dict = {
"home.recentProjects": "المشاريع الحديثة",
"home.empty.title": "لا توجد مشاريع حديثة",
"home.empty.description": "ابدأ بفتح مشروع محلي",
"home.title": "الرئيسية",
"home.projects": "المشاريع",
"home.project.add": "إضافة مشروع",
"home.recentlyClosed": "المغلقة مؤخرًا",
"home.server.collapse": "طي مشاريع الخادم",
"home.server.expand": "توسيع مشاريع الخادم",
"home.sessions.search.placeholder": "البحث في الجلسات",
"home.sessions.search.placeholder.scoped": "البحث في جلسات {{scope}}",
"home.sessions.search.sessions": "الجلسات",
"home.sessions.search.noResults": "لم يتم العثور على جلسات لـ {{query}}",
"home.sessions.empty": "لا يوجد شيء هنا بعد",
"home.sessions.empty.description": "أنشئ جلسة للبدء",
"home.sessions.group.today": "اليوم",
"home.sessions.group.yesterday": "أمس",
"home.sessions.group.older": "الأقدم",
"session.tab.session": "جلسة",
"session.tab.review": "مراجعة",
"session.tab.context": "سياق",
"session.tab.unknown": "جلسة غير معروفة",
"session.panel.reviewAndFiles": "المراجعة والملفات",
"session.error.notFound": "لا يمكن العثور على هذه الجلسة",
"session.error.notFound.description": "يشير هذا التبويب إلى جلسة لم تعد موجودة على هذا الخادم.",
"session.error.notFound.closeTab": "إغلاق علامة التبويب",
"session.error.serverConnection": "تعذر الاتصال بهذا الخادم",
"session.review.filesChanged": "تم تغيير {{count}} ملفات",
"session.review.change.one": "تغيير",
"session.review.change.other": "تغييرات",
"session.review.loadingChanges": "جارٍ تحميل التغييرات...",
"session.review.empty": "لا توجد تغييرات في هذه الجلسة بعد",
"session.review.noChanges": "لا توجد تغييرات",
"session.review.noUncommittedChanges": "لا توجد تغييرات غير مُودَعة بعد",
"session.review.noBranchChanges": "لا توجد تغييرات في الفرع بعد",
"session.review.noVcs": "لم يتم اكتشاف نظام التحكم في الإصدار Git، لن يتم عرض التغييرات",
"session.review.noSnapshot": "تم تعطيل تتبع اللقطات في التكوين، لذا فإن تغييرات الجلسة غير متوفرة",
"session.files.selectToOpen": "اختر ملفًا لفتحه",
@@ -475,6 +580,10 @@ export const dict = {
"session.todo.title": "المهام",
"session.todo.collapse": "طي",
"session.todo.expand": "توسيع",
"session.question.minimize": "تصغير السؤال",
"session.question.restore": "استعادة السؤال",
"session.question.pending.one": "{{count}} سؤال معلق",
"session.question.pending.other": "الأسئلة المعلقة: {{count}}",
"session.followupDock.summary.one": "{{count}} رسالة في الانتظار",
"session.followupDock.summary.other": "{{count}} رسائل في الانتظار",
"session.followupDock.sendNow": "إرسال الآن",
@@ -487,9 +596,16 @@ export const dict = {
"session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها",
"session.revertDock.restore": "استعادة الرسالة",
"session.new.title": "ابنِ أي شيء",
"session.new.project.new": "مشروع جديد",
"session.new.project.search": "البحث عن المشاريع",
"session.new.project.add": "إضافة مشروع",
"session.new.worktree.main": "الفرع الرئيسي",
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
"session.new.workspace.runIn": "تشغيل الجلسة في",
"session.new.workspace.triggerLocal": "محلي",
"session.new.workspace.local": "المستودع المحلي",
"session.new.workspace.existing": "مساحة عمل…",
"session.new.lastModified": "آخر تعديل",
"session.header.search.placeholder": "بحث {{project}}",
"session.header.searchFiles": "بحث عن الملفات",
@@ -550,6 +666,8 @@ export const dict = {
"sidebar.project.recentSessions": "الجلسات الحديثة",
"sidebar.project.viewAllSessions": "عرض جميع الجلسات",
"sidebar.project.clearNotifications": "مسح الإشعارات",
"sidebar.empty.title": "لا توجد مشاريع مفتوحة",
"sidebar.empty.description": "افتح مشروعًا للبدء",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "سطح المكتب",
"settings.section.server": "الخادم",
@@ -559,6 +677,7 @@ export const dict = {
"settings.desktop.wsl.title": "تكامل WSL",
"settings.desktop.wsl.description": "تشغيل خادم OpenCode داخل WSL على Windows.",
"settings.general.section.appearance": "المظهر",
"settings.general.section.advanced": "متقدم",
"settings.general.section.notifications": "إشعارات النظام",
"settings.general.section.updates": "التحديثات",
"settings.general.section.sounds": "المؤثرات الصوتية",
@@ -566,6 +685,11 @@ export const dict = {
"settings.general.section.display": "شاشة العرض",
"settings.general.row.language.title": "اللغة",
"settings.general.row.language.description": "تغيير لغة العرض لـ OpenCode",
"settings.general.row.shell.title": "Shell المحطة الطرفية",
"settings.general.row.shell.description":
"اختر shell المستخدم في المحطة الطرفية. تُستخدم واجهات shell المتوافقة أيضًا في استدعاءات أدوات الوكيل.",
"settings.general.row.shell.autoDefault": "تلقائي (افتراضي)",
"settings.general.row.shell.terminalOnly": "المحطة الطرفية فقط",
"settings.general.row.appearance.title": "المظهر",
"settings.general.row.appearance.description": "تخصيص كيفية ظهور OpenCode على جهازك",
"settings.general.row.colorScheme.title": "مخطط الألوان",
@@ -574,14 +698,29 @@ export const dict = {
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
"settings.general.row.font.title": "خط الكود",
"settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية",
"settings.general.row.terminalFont.title": "Terminal Font",
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.terminalFont.title": "خط الطرفية",
"settings.general.row.terminalFont.description": "خصّص الخط المستخدم في الطرفية",
"settings.general.row.uiFont.title": "خط الواجهة",
"settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
"settings.general.row.followup.title": "سلوك المتابعة",
"settings.general.row.followup.description": "اختر ما إذا كانت طلبات المتابعة توجه فورًا أو تنتظر في قائمة انتظار",
"settings.general.row.followup.option.queue": "قائمة انتظار",
"settings.general.row.followup.option.steer": "توجيه",
"settings.general.row.showFileTree.title": "شجرة الملفات",
"settings.general.row.showFileTree.description": "إظهار لوحة شجرة الملفات في الجلسات",
"settings.general.row.showNavigation.title": "عناصر التحكم في التنقل",
"settings.general.row.showNavigation.description": "إظهار زري الرجوع والتقدم في شريط عنوان سطح المكتب",
"settings.general.row.showSearch.title": "لوحة الأوامر",
"settings.general.row.showSearch.description": "إظهار زر البحث ولوحة الأوامر في شريط العنوان",
"settings.general.row.showTerminal.title": "المحطة الطرفية",
"settings.general.row.showTerminal.description": "إظهار زر المحطة الطرفية في شريط عنوان سطح المكتب",
"settings.general.row.showStatus.title": "حالة الخادم",
"settings.general.row.showStatus.description": "إظهار زر حالة الخادم في شريط العنوان",
"settings.general.row.mobileTitlebarBottom.title": "التنقل السفلي",
"settings.general.row.mobileTitlebarBottom.description":
"وضع شريط العنوان وعلامات تبويب الجلسات أسفل الشاشة على الأجهزة المحمولة",
"settings.general.row.showCustomAgents.title": "الوكلاء المخصصون",
"settings.general.row.showCustomAgents.description": "إظهار محدد الوكلاء في محرر الرسائل",
"settings.general.row.reasoningSummaries.title": "إظهار ملخصات الاستنتاج",
"settings.general.row.reasoningSummaries.description": "عرض ملخصات استنتاج النموذج في الشريط الزمني",
"settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
@@ -590,6 +729,11 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
"settings.general.row.editToolPartsExpanded.description":
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
"settings.general.row.newLayoutDesigns.title": "التخطيط والتصاميم الجديدة",
"settings.general.row.newLayoutDesigns.description":
"تمكين التخطيط والصفحة الرئيسية ومحرر الرسائل وواجهة الجلسة المعاد تصميمها",
"settings.general.row.pinchZoom.title": "التكبير بإيماءة القرص",
"settings.general.row.pinchZoom.description": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير",
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
"settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
"settings.general.row.wayland.tooltip":
@@ -602,6 +746,8 @@ export const dict = {
"settings.updates.row.check.description": "التحقق يدويًا من التحديثات وتثبيتها إذا كانت متاحة",
"settings.updates.action.checkNow": "تحقق الآن",
"settings.updates.action.checking": "جارٍ التحقق...",
"settings.updates.action.downloading": "جارٍ التنزيل...",
"settings.updates.action.installing": "جارٍ التثبيت...",
"settings.updates.toast.latest.title": "أنت على آخر إصدار",
"settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.",
"sound.option.none": "بلا",

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