Compare commits

...

206 Commits

Author SHA1 Message Date
James Long 537ce02685 test(core): simplify command layer wiring 2026-06-20 21:46:35 -04:00
James Long d3bbfff826 test(opencode): simplify message pagination layer wiring (#33157) 2026-06-20 21:44:37 -04:00
James Long 468f425e76 test(opencode): simplify session retry layer wiring (#33155) 2026-06-20 21:44:02 -04:00
James Long d59619fffd test(opencode): simplify git layer wiring (#33156) 2026-06-20 21:43:03 -04:00
opencode-agent[bot] 5606d2bab9 chore: update nix node_modules hashes 2026-06-21 00:19:31 +00:00
opencode e84d94d998 sync release versions for v1.17.9 2026-06-21 00:03:08 +00:00
Kit Langton 4f1ae1604e chore: upgrade Effect to beta 83 (#32340) 2026-06-20 19:57:29 -04:00
Dax d99f86b28d fix(tui): separate subagent tool rows (#33158) 2026-06-20 18:58:27 -04:00
imranshaiedi-byte 22cc758b1a feat(opencode): expose High/Max thinking variants for GLM-5.2 (#32446)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-20 16:59:08 -05:00
opencode-agent[bot] 0b7ec51d0a chore: generate 2026-06-20 21:05:57 +00:00
Kit Langton 4f1a9d7aef fix(core): honor configured agent step limits (#33142) 2026-06-20 23:04:30 +02:00
Adam 503309d244 fix(stats): tolerate pending user column 2026-06-20 15:56:52 -05:00
opencode-agent[bot] 1c76587ce2 chore: generate 2026-06-20 19:56:12 +00:00
Adam 24c70ec974 feat(stats): add unique user charts 2026-06-20 14:54:45 -05:00
Lucas Kim 2d993cd0d5 fix(experimental llm pkg): forward topK to Converse via additionalModelRequestFields (#33030) 2026-06-20 14:35:05 -05:00
卫斯李 babe5070e2 fix(opencode): use toLowerCase for Devstral model detection (#33109)
Co-authored-by: Robin1987China <fanrenzhige@163.com>
2026-06-20 14:19:27 -05:00
Dax 009f3799cd refactor(tui): simplify inline tool spacing (#33097) 2026-06-20 09:35:20 -04:00
Adam 95237a90a7 fix(stats): align model peers ranking
deploy / deploy (push) Has been cancelled
2026-06-20 06:14:24 -05:00
Aiden Cline e6cdc543f3 fix(tui): render console org load errors inline (#33040)
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
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-06-19 19:41:22 -04:00
Aiden Cline f092bafe88 tweak: remove steering wrapper that can bust cache (#33039) 2026-06-19 19:10:09 -04:00
Grant Martin 3f1fffeb6d fix(core): fix command docs in customize-opencode skill (#32718) 2026-06-19 18:22:27 -04:00
Luke Parker c6083a474c test(app): add manual performance diagnostics (#32937) 2026-06-19 19:39:02 +02:00
opencode-agent[bot] 10ec856ff2 chore: generate 2026-06-19 16:38:37 +00:00
Adam 0f6c9b3874 chore(stats): update data seo metadata
deploy / deploy (push) Has been cancelled
2026-06-19 11:36:42 -05:00
opencode-agent[bot] 355a0bcf5b chore: generate
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-06-18 12:56:22 +00:00
Aiden Cline 2892e97c57 fix(tui): gate background shortcut by capability (#32837) 2026-06-18 14:54:26 +02:00
Frank 62c746f2e8 zen: budget
deploy / deploy (push) Has been cancelled
2026-06-18 14:27:02 +02:00
Aiden Cline ec50db334b fix(opencode): pass configured headers to Copilot models (#32815) 2026-06-18 11:48:01 +02:00
opencode 8716c4309a sync release versions for v1.17.8
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
2026-06-17 21:27:16 +00:00
Frank 10b6672be1 go: glm 5.2
deploy / deploy (push) Has been cancelled
2026-06-17 13:04:43 +02:00
Adam 85a79292e6 fix(stats): map lab aliases 2026-06-17 05:38:14 -05:00
Luke Parker 5c9e4ff21b feat(app): add v2 home tab toggle (#32191) 2026-06-17 12:37:48 +02:00
opencode-agent[bot] 417ad240c7 chore: generate 2026-06-17 09:04:36 +00:00
Adam 1e63e76192 fix(stats): scope model pages to go 2026-06-17 04:02:22 -05:00
Jason Quense 213ff3f2d7 fix(opencode): sanitize OpenAI MCP tool schemas (#32489)
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
Co-authored-by: jquense <jquense@ramp.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-06-17 02:00:58 +02:00
opencode-agent[bot] 3ab3d04ec7 chore: update nix node_modules hashes 2026-06-16 17:12:18 +00:00
Keefe Tang 8fd5753f76 fix(provider): pass apiKey to createUnified for Cloudflare AI Gateway (#32052)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-06-16 19:04:38 +02:00
opencode-agent[bot] 273efdeee7 chore: generate 2026-06-16 16:57:04 +00:00
Luke Parker 3b811bd019 feat(app): make session timelines much faster AND without flicker or scroll jumps (#32331) 2026-06-16 18:53:57 +02:00
opencode-agent[bot] e772664389 chore: update nix node_modules hashes 2026-06-16 15:57:58 +00:00
opencode-agent[bot] 1c2c2d6b52 chore: generate 2026-06-16 15:45:29 +00:00
Luke Parker 88f5b9a90e experiment: better web picker using @pierre/tree (#31208) 2026-06-16 17:43:23 +02:00
Aiden Cline 25cb2be619 fix(mcp): default tool schema properties (#32568) 2026-06-16 16:27:20 +02:00
Aiden Cline bd84c32860 fix(web): persist docs language selection (#32551) 2026-06-16 13:27:05 +02:00
Aiden Cline 94652cfe4a fix(tui): render move errors inline (#32241) 2026-06-16 13:13:11 +02:00
adamelmore 3a2ff11be8 Revert "fix(mcp): type tool error content"
This reverts commit 9258e8ca86.
2026-06-16 12:06:26 +02:00
RAMA a98d5732c0 fix(mcp): enable progress timeout resets (#32477)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-16 12:03:18 +02:00
Aiden Cline 5d0f86606a fix(mcp): stop idle OAuth callback server (#32245)
deploy / deploy (push) Has been cancelled
generate / generate (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
publish / version (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
2026-06-14 22:36:21 -04:00
opencode-agent[bot] 98d66e9a8b chore: generate
deploy / deploy (push) Has been cancelled
2026-06-15 02:29:40 +00:00
opencode-agent[bot] 0dbfb6bc0b chore: update nix node_modules hashes 2026-06-15 02:28:10 +00:00
Adam 9258e8ca86 fix(mcp): type tool error content 2026-06-14 21:24:54 -05:00
Adam 9fdfa23f01 fix(stats): align homepage model ranks 2026-06-14 21:22:12 -05:00
Aiden Cline dfb616f067 fix(mcp): handle tool result errors (#32244) 2026-06-14 22:16:39 -04:00
Adam 1338d7b47a fix(stats): rank model pages by week 2026-06-14 21:00:35 -05:00
Aiden Cline a774c62eac chore(opencode): consolidate escape logic (#32360) 2026-06-14 21:27:02 -04:00
Adam a9a4b2f00f fix(stats): scope data charts to go 2026-06-14 20:21:49 -05:00
Aiden Cline e4ccb505bf fix(mcp): escape OAuth callback errors (#32242) 2026-06-14 21:14:36 -04:00
opencode 85e278b728 sync release versions for v1.17.7 2026-06-14 18:49:21 +00:00
Dax Raad 87c33b3d85 fix(plugin): reuse active server for client requests
Temporarily route plugin SDK calls through the active listener so they reuse its instance store instead of initializing plugins again through the default app. Keep the in-process fallback for commands without a listener. This is intentionally a narrow bridge until v2 owns plugin clients in the correct shared server runtime.
2026-06-14 13:35:05 -04:00
opencode-agent[bot] 3f81402663 chore: generate 2026-06-14 13:41:30 +00:00
Brendan Allan d37ddc501c feat(app): add prompt input story (#32308) 2026-06-14 13:40:09 +00:00
Shoubhit Dash 3e523d506c fix(tui): match @ mention items by name, not description or uri (#32309) 2026-06-14 13:11:01 +00:00
Shoubhit Dash e4d4b07e7d test(acp): make shell workdir location assertion windows-safe (#32306) 2026-06-14 14:54:35 +02:00
Shoubhit Dash 51461429f4 fix(acp): show shell command in ACP tool calls (#32304)
Co-authored-by: Mert Can Demir <validatedev@gmail.com>
2026-06-14 14:46:27 +02:00
opencode-agent[bot] 3ab19bfd7d chore: generate 2026-06-14 12:46:12 +00:00
Dax 0cf3ee4406 refactor(core): derive catalog availability from integrations (#32272) 2026-06-14 14:44:50 +02:00
opencode-agent[bot] 4810df0a71 chore: generate 2026-06-14 11:25:37 +00:00
Brendan Allan 010b456dfd feat(app): scope sdk/sync hooks per-route so /new-session targets its draft server (#32290) 2026-06-14 13:24:10 +02:00
opencode-agent[bot] c81cd3202c chore: generate 2026-06-14 11:19:10 +00:00
Shoubhit Dash 7ad68f8150 fix(server): apply plugin pty environment (#32296) 2026-06-14 13:17:48 +02:00
opencode-agent[bot] 8cc2276dba chore: generate 2026-06-14 10:48:03 +00:00
Shoubhit Dash f2cf607376 refactor(core): canonicalize pty service (#32182) 2026-06-14 12:46:39 +02:00
Frank 7efade2d53 zen: monitor budget
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
deploy / deploy (push) Has been cancelled
containers / build (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-06-14 02:00:50 -04:00
Aiden Cline 231f4944c7 fix(mcp): use SDK protocol version in debug (#32243) 2026-06-13 23:54:45 -05:00
Aiden Cline f55a931f59 feat(mcp): support client roots (#32230) 2026-06-13 19:13:15 -05:00
opencode 42f339c681 sync release versions for v1.17.6 2026-06-13 22:39:08 +00:00
James Long 409ba229ab fix(core): ignore unavailable project copy roots (#32234) 2026-06-13 18:10:59 -04:00
Aiden Cline 252223a4a7 fix(tui): handle move directory errors (#32226) 2026-06-13 15:57:14 -05:00
Aiden Cline 11fd6f8255 chore(mcp): declare client capabilities (#32222) 2026-06-13 15:29:38 -05:00
opencode 7143bf8ff0 sync release versions for v1.17.5 2026-06-13 19:59:03 +00:00
opencode-agent[bot] 414c037b21 chore: generate 2026-06-13 19:14:37 +00:00
OpeOginni 632f94fa68 fix(opencode): add authorization header to fetch requests in RunCommand (#29877) 2026-06-13 14:13:15 -05:00
opencode-agent[bot] a6e3afe048 chore: generate 2026-06-13 18:11:18 +00:00
santigc6 3f174531b9 feat(opencode): add external browser OAuth for snowflake cortex provider (#31700)
Co-authored-by: santiago.gonzalezcarvajcentenera <santiago.gonzalezcarvajcentenera@colaborador.elcorteingles.es>
Co-authored-by: David Fierro <14184197+davidfierro@users.noreply.github.com>
Co-authored-by: Kamesh Sampath <kamesh.sampath@hotmail.com>
Co-authored-by: Cortex Code <noreply@snowflake.com>
2026-06-13 13:09:45 -05:00
opencode-agent[bot] fcca731a96 chore: update nix node_modules hashes 2026-06-13 16:45:25 +00:00
Aiden Cline c7dee9c609 fix(opencode): recover expired MCP sessions (#32088) 2026-06-13 11:20:59 -05:00
Luke Parker 45e4606fa4 feat(app): bring v2 visibility settings to web (#32174) 2026-06-13 13:13:12 +02:00
Luke Parker 9ae4a5139f fix(app): expand terminal resize gutter hitbox (#32169) 2026-06-13 09:23:01 +00:00
opencode-agent[bot] d73348719e chore: generate 2026-06-13 07:34:53 +00:00
Luke Parker 2630f457b9 fix(app): fade overflowing titlebar tabs (#32082) 2026-06-13 17:33:31 +10:00
opencode-agent[bot] dbbe67f066 chore: generate
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-06-12 22:52:55 +00:00
Sebastian fff0ec294c fix(tui): prevent duplicate renderable IDs (#32110) 2026-06-13 00:51:32 +02:00
Aiden Cline cf2d1dd3e9 fix(tui): restore subtask invocation spacing (#32109) 2026-06-12 17:18:05 -05:00
Stefan Avram 27ca0f8825 docs: update North Mini Code privacy terms (#32096) 2026-06-12 16:44:25 -04:00
opencode-agent[bot] 73dbd8a312 chore: generate 2026-06-12 20:00:30 +00:00
Frank 443f103ee3 go: kimi 2.7 code
deploy / deploy (push) Has been cancelled
2026-06-12 15:57:13 -04:00
opencode-agent[bot] 6c36b585c3 chore: generate 2026-06-12 18:46:51 +00:00
James Long c2e6b18076 feat(core): refactor project copies for v2 (#31943) 2026-06-12 14:45:16 -04:00
opencode-agent[bot] 8d97c8d412 chore: generate 2026-06-12 18:35:01 +00:00
Adam f8b357b267 fix(data): mobile breakpoint for nav 2026-06-12 13:33:00 -05:00
Adam 621796d8ce feat(stats): add world map markers 2026-06-12 13:32:59 -05:00
Adam ba2455ecc7 feat(stats): use catalog pricing in efficiency and token costs 2026-06-12 13:32:59 -05:00
Adam 44308dfd7e fix(stats): tighten truncated label line height 2026-06-12 13:32:59 -05:00
opencode-agent[bot] b000256f57 chore: generate 2026-06-12 18:16:37 +00:00
James Long 30b2544fe1 refactor(opencode): build server from layer nodes (#32086) 2026-06-12 14:14:51 -04:00
Aiden Cline 1b096b4148 fix(opencode): clear closed MCP clients (#32084) 2026-06-12 12:39:14 -05:00
Aiden Cline be227503af fix(opencode): expose structured MCP output (#32074) 2026-06-12 10:48:00 -05:00
Adam 4ddfa7c6fa fix(stats): reorder leaderboard cards 2026-06-12 08:26:18 -05:00
Jack 2415434ad9 feat(go): promote MiniMax M3 usage limits (#31986) 2026-06-12 20:50:57 +08:00
opencode-agent[bot] bf4c647653 chore: generate 2026-06-12 09:29:10 +00:00
Aarav Sareen f35bb5184c feat(app): update oc-2 colors (#31071) 2026-06-12 09:27:36 +00:00
opencode-agent[bot] 2c5335d84c chore: generate
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
containers / build (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
2026-06-12 06:47:04 +00:00
Dax Raad 7793db3ac8 fix(core): preserve credential schema compatibility 2026-06-12 02:45:25 -04:00
opencode-agent[bot] 5f77482a29 chore: generate 2026-06-12 06:16:50 +00:00
Dax 30aec297d8 refactor(core): simplify integration credentials (#31968) 2026-06-12 06:15:25 +00:00
Tony Worm a9c810cbbc fix(tui): double file content injection in commands using $ARGUMENTS (#31245)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-11 23:19:15 -05:00
opencode fe2e4e21d0 sync release versions for v1.17.4 2026-06-12 02:19:38 +00:00
David Hill 03a2504d3d fix(tui): shorten move session description (#31967) 2026-06-11 20:10:25 -04:00
Frank bace18ccd3 zen: make payment method off_session
deploy / deploy (push) Has been cancelled
2026-06-11 17:50:07 -04:00
opencode-agent[bot] cd2e6f06ee chore: generate 2026-06-11 21:07:32 +00:00
Grant Martin 7e7ad37736 feat(opencode): support cwd on local MCP servers (#30676)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-11 16:05:45 -05:00
Aiden Cline ca8db315a9 fix(tui): show prompt submission errors (#31949) 2026-06-11 14:33:01 -05:00
opencode-agent[bot] 9eb07ab55a chore: generate 2026-06-11 17:49:51 +00:00
Adam e07e420637 fix(stats): align deep route metadata with /data (#31930) 2026-06-11 12:47:45 -05:00
opencode-agent[bot] 84f94e6143 chore: generate 2026-06-11 17:23:18 +00:00
Aiden Cline a150424c16 fix(tui): show terminal tool failure labels (#31934) 2026-06-11 12:19:31 -05:00
Linus Schlumberger 2e71292f2f fix(gemini): prevent gemini incompatibility with some tools (#31877) 2026-06-11 11:30:50 -05:00
Kevin Dawkins e2527db3c7 fix(opencode): surface content-filter finish reason as visible error (#31745)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-11 10:41:11 -05:00
opencode-agent[bot] a1dee8b273 chore: update nix node_modules hashes 2026-06-11 15:39:13 +00:00
Vladimir Glafirov 04e5ca9d1b chore: bump gitlab-ai-provider to 6.9.3 and opencode-gitlab-auth to 2.1.0 (#31913) 2026-06-11 10:24:20 -05:00
opencode-agent[bot] 6dd4d1473c chore: generate 2026-06-11 15:22:48 +00:00
Dax 31c5454ee6 refactor(server): serve raw filesystem content (#31911) 2026-06-11 11:20:11 -04:00
opencode-agent[bot] 31b233e9db chore: generate 2026-06-11 15:04:14 +00:00
Dax 567d6ed1a3 fix(tui): restore legacy sync consumers (#31908) 2026-06-11 15:01:20 +00:00
James Long 2bde20c2ba test(opencode): simplify snapshot race layer wiring (#31827) 2026-06-11 10:03:46 -04:00
tobwen 92c70c9c3d fix(tui): preserve exit epilogue during scoped shutdown (#31805) 2026-06-11 12:22:48 +00:00
opencode-agent[bot] 318dbe93ba chore: update nix node_modules hashes 2026-06-11 07:42:21 +00:00
Sebastian 8bd249d8cf upgrade opentui-spinner (#31561) 2026-06-11 09:20:59 +02:00
opencode-agent[bot] 710e408709 chore: generate
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-06-11 06:32:44 +00:00
Dax dac0dd5309 feat(core): add connector authentication (#31837) 2026-06-11 06:31:17 +00:00
Aiden Cline bf05e8a122 fix(mcp): preserve headers during auth and debug (#31802) 2026-06-10 23:12:02 -05:00
opencode-agent[bot] 51b10b128e chore: generate 2026-06-11 03:36:12 +00:00
Dax 47a45601fd refactor(tui): replace v2 sync with data context (#31826) 2026-06-10 23:34:35 -04:00
opencode-agent[bot] 69623c2b79 chore: generate 2026-06-11 02:56:49 +00:00
James Long 38536cf733 test(opencode): simplify processor layer wiring (#31823) 2026-06-10 22:55:21 -04:00
Dax 8bf0675997 feat(server): add v2 session API endpoints (#31822) 2026-06-11 02:55:01 +00:00
opencode-agent[bot] ff967e582c chore: generate 2026-06-11 02:20:38 +00:00
James Long 20bf18ffb4 test(opencode): simplify share layer wiring (#31811) 2026-06-10 22:19:06 -04:00
Dmitriy Kovalenko 51891d56e7 fix(snapshot): reuse source git objects to avoid re-hashing huge repos (#31798) 2026-06-11 00:25:55 +00:00
Dax Raad cc22646467 docs: add branch naming guidance 2026-06-10 19:09:56 -04:00
James Long eb70b6137b test(opencode): simplify test registry layer wiring (#31761) 2026-06-10 17:21:40 -04:00
opencode-agent[bot] 6e2bcafd34 chore: update nix node_modules hashes 2026-06-10 20:09:04 +00:00
Aiden Cline 07b983e82f feat(mcp): support server log notifications (#31752) 2026-06-10 14:59:36 -05:00
Simon Klee c51a1588c7 tui: fix session list search filtering (#31748)
Fix #31182
Close #31211
2026-06-10 21:47:05 +02:00
Aiden Cline f43b0d3afd fix(mcp): apply timeouts to catalog requests (#31618) 2026-06-10 14:38:08 -05:00
Vladimir Glafirov 722f4dd416 chore: pin gitlab-ai-provider to 6.9.0 (#31741) 2026-06-10 14:33:31 -05:00
Dax Raad bb82aab5c8 update opencode.jsonc 2026-06-10 15:30:56 -04:00
opencode 936363e7a8 sync release versions for v1.17.3
deploy / deploy (push) Has been cancelled
2026-06-10 18:00:25 +00:00
Adam 8688ed7e3e feat(web): data link 2026-06-10 12:55:44 -05:00
opencode-agent[bot] 5b54203168 chore: update nix node_modules hashes 2026-06-10 17:44:15 +00:00
Vladimir Glafirov bed780fac9 chore: bump gitlab-ai-provider to 6.9.1 (#31728) 2026-06-10 12:30:43 -05:00
Dax Raad 14ec7ed5b3 fix fff disabling logic 2026-06-10 13:29:14 -04:00
opencode 2c65273204 sync release versions for v1.17.2 2026-06-10 16:42:54 +00:00
Filip 2e0f88d0e3 fix(desktop): restore linux launcher identity (#31709) 2026-06-10 15:54:49 +00:00
opencode-agent[bot] e1073e5d18 chore: generate 2026-06-10 15:48:14 +00:00
mridul 649618c50a fix(app): restore device attachment picker (#31707) 2026-06-10 15:46:09 +00:00
Ayush Thakur 02608a4e97 fix: recover from expired enterprise auth on remote config load (#31661) 2026-06-10 11:02:09 -04:00
Aiden Cline 3ad6923c61 fix(opencode): let subagents use their own permissions (#31696) 2026-06-10 09:26:08 -05:00
Dax Raad e4300e9b74 fix(core): disable fff by default on windows 2026-06-10 10:22:00 -04:00
Dax Raad 538cfaff0d feat(core): enable fff by default 2026-06-10 10:21:25 -04:00
Dax Raad 1dad38d1b5 fix(core): do not gate fff on initial scan 2026-06-10 10:19:52 -04:00
Aiden Cline c9e2a38bf4 ci: change model from gpt-5.4-nano to gpt-5.4-mini (#31695) 2026-06-10 09:14:27 -05:00
Dax Raad 5863e1254c put fff behind flag 2026-06-10 10:13:54 -04:00
opencode 4c9abff445 sync release versions for v1.17.1 2026-06-10 11:37:58 +00:00
opencode-agent[bot] 2cf68f32b5 chore: generate 2026-06-10 10:34:23 +00:00
Luke Parker 90fb32be30 fix(core): accept deprecated reference config key (#31659) 2026-06-10 10:32:35 +00:00
Frank 97e713e8aa zen: deepseek v4 pro 2026-06-10 04:05:03 -04:00
opencode-agent[bot] 826419127a chore: update nix node_modules hashes
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
containers / build (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-06-10 06:07:15 +00:00
Luke Parker e0449c0b96 fix(desktop): restore macOS auto-updates (#31621) 2026-06-10 05:49:11 +00:00
opencode-agent[bot] 5e342f7114 chore: update nix node_modules hashes 2026-06-10 05:00:09 +00:00
Aiden Cline 174ab58343 fix(mcp): apply timeouts to prompts and resources (#31612) 2026-06-09 23:58:40 -05:00
Aiden Cline 954d618790 fix(opencode): support Anthropic fallback responses (#31611) 2026-06-09 23:46:01 -05:00
Aiden Cline 91073360c6 fix(mcp): make client creation failure-safe (#31595) 2026-06-09 23:24:41 -05:00
Dax 8a2cfc00c9 feat(core): add project reference guidance (#31601) 2026-06-10 04:08:26 +00:00
Brendan Allan 0fc33e2a06 feat(app): /new-session route for new design (#31457) 2026-06-10 11:35:50 +08:00
opencode 6c6ed68b5a sync release versions for v1.17.0 2026-06-10 03:12:32 +00:00
opencode-agent[bot] e9106efbdc chore: update nix node_modules hashes 2026-06-10 02:47:36 +00:00
Dmitriy Kovalenko e9e2612706 chore: Update fff to 0.9.4 (#31583) 2026-06-09 22:28:34 -04:00
opencode-agent[bot] 4ae468ff06 chore: generate 2026-06-10 02:12:40 +00:00
Dax Raad 785918262a fix(core): expose partial filesystem scan results 2026-06-09 22:10:53 -04:00
opencode-agent[bot] be8fee5c2b chore: generate 2026-06-10 02:05:59 +00:00
Dax Raad 4597c687fa fix(core): prefer shorter paths for tied search scores 2026-06-09 22:04:17 -04:00
Dax c4aa049042 fix(tui): let gutter replace current marker (#31586) 2026-06-09 21:07:11 -04:00
opencode-agent[bot] 1d46b5c33c chore: update nix node_modules hashes 2026-06-10 00:51:11 +00:00
opencode-agent[bot] 6ae6f0fe8f chore: generate 2026-06-10 00:39:58 +00:00
Dax a0409e64d8 refactor(core): unify filesystem search service (#31566) 2026-06-09 20:38:02 -04:00
opencode-agent[bot] ce4e658e3f chore: update nix node_modules hashes 2026-06-09 23:55:49 +00:00
opencode-agent[bot] 215fb39fc5 chore: generate 2026-06-09 23:36:28 +00:00
Luke Parker 1cc94bb23e fix(desktop): update Electron stack and panel layout (#31571) 2026-06-09 23:34:35 +00:00
Frank bc42187c58 zen: update email
deploy / deploy (push) Has been cancelled
2026-06-09 19:31:54 -04:00
Songchao Wang 80c0b06980 feat: add X-Session-Id header for proxy cache routing affinity (#31511) 2026-06-09 17:01:17 -05:00
Aiden Cline 381eabb970 refactor(mcp): simplify service helpers (#31549) 2026-06-09 16:59:24 -05:00
opencode-agent[bot] c939aa04fe chore: generate 2026-06-09 19:34:54 +00:00
Frank 9b8e356fca zen: add claude fable 5 2026-06-09 15:32:14 -04:00
James Long 07e5ea9367 feat(opencode): add typed application layer graph (#31531) 2026-06-09 15:31:31 -04:00
opencode-agent[bot] 7a54a2c49c chore: generate 2026-06-09 19:10:44 +00:00
733 changed files with 30139 additions and 58132 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
---
mode: primary
hidden: true
model: opencode/gpt-5.4-nano
model: opencode/gpt-5.4-mini
color: "#44BA81"
tools:
"*": false
+9 -2
View File
@@ -2,8 +2,15 @@
"$schema": "https://opencode.ai/config.json",
"provider": {},
"permission": {},
"reference": {
"effect": "github.com/Effect-TS/effect-smol",
"references": {
"effect": {
"repository": "github.com/Effect-TS/effect-smol",
"description": "Use for Effect v4 and effect-smol implementation details",
},
"opencode-local": {
"path": "~/.local/share/opencode",
"description": "Contains opencode logs and data",
},
},
"mcp": {},
"tools": {
+6
View File
@@ -2,6 +2,12 @@
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
## Branch Names
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
Examples: `session-recovery`, `fix-scroll-state`, `regenerate-sdk`.
## Commits and PR Titles
Use conventional commit-style messages and PR titles: `type(scope): summary`.
+5
View File
@@ -52,6 +52,9 @@ _Avoid_: Request body, wire options
**Generation Controls**:
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
**PTY Environment**:
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
## Relationships
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
@@ -99,6 +102,8 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
+311 -124
View File
@@ -29,12 +29,13 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
@@ -52,6 +53,7 @@
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:",
@@ -65,7 +67,6 @@
"solid-js": "catalog:",
"solid-list": "catalog:",
"tailwindcss": "catalog:",
"virtua": "catalog:",
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
@@ -85,7 +86,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.16.2",
"version": "1.17.9",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -110,7 +111,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -146,7 +147,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -173,9 +174,9 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@openauthjs/openauth": "0.0.0-20250322224806",
@@ -195,7 +196,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -219,7 +220,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -239,14 +240,14 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.16.2",
"version": "1.17.9",
"bin": {
"opencode": "./bin/opencode",
},
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
@@ -268,7 +269,7 @@
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-bun": "0.9.3",
"@ff-labs/fff-bun": "0.9.4",
"@lydell/node-pty": "catalog:",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
@@ -288,7 +289,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.8.0",
"gitlab-ai-provider": "6.9.3",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -330,14 +331,14 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
"electron-context-menu": "4.1.2",
"electron-log": "^5",
"electron-store": "^10",
"electron-updater": "^6",
"electron-store": "11.0.2",
"electron-updater": "6.8.9",
"electron-window-state": "^5.0.3",
"marked": "^15",
},
@@ -356,8 +357,8 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"@valibot/to-json-schema": "1.6.0",
"electron": "41.2.1",
"electron-builder": "^26",
"electron": "42.3.3",
"electron-builder": "26.15.2",
"electron-vite": "^5",
"solid-js": "catalog:",
"sury": "11.0.0-alpha.4",
@@ -384,7 +385,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -398,7 +399,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"effect": "catalog:",
},
@@ -410,7 +411,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -441,7 +442,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -457,10 +458,10 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "0.0.0",
"version": "1.17.9",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.74",
"@effect/platform-node-shared": "4.0.0-beta.74",
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
@@ -471,12 +472,12 @@
"typescript": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-beta.74",
"effect": "4.0.0-beta.83",
},
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -494,7 +495,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.16.2",
"version": "1.17.9",
"bin": {
"opencode": "./bin/opencode",
},
@@ -504,7 +505,7 @@
"@agentclientprotocol/sdk": "0.21.0",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
@@ -525,6 +526,7 @@
"@clack/prompts": "1.0.0-alpha.1",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@ff-labs/fff-bun": "0.9.4",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@modelcontextprotocol/sdk": "1.29.0",
"@octokit/graphql": "9.0.2",
@@ -563,7 +565,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.8.0",
"gitlab-ai-provider": "6.9.3",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -575,7 +577,7 @@
"minimatch": "10.0.3",
"npm-package-arg": "13.0.2",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.1",
"opencode-gitlab-auth": "2.1.0",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "catalog:",
"partial-json": "0.1.7",
@@ -621,7 +623,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -659,7 +661,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -674,7 +676,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"drizzle-orm": "catalog:",
@@ -688,7 +690,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -701,7 +703,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
@@ -734,7 +736,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -753,7 +755,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -793,7 +795,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "0.0.0",
"version": "1.17.9",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -802,7 +804,6 @@
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/scheduled": "1.5.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
@@ -821,12 +822,13 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3",
"@solid-primitives/event-listener": "2.4.5",
@@ -852,7 +854,6 @@
"solid-js": "catalog:",
"solid-list": "catalog:",
"strip-ansi": "7.1.2",
"virtua": "catalog:",
},
"devDependencies": {
"@tailwindcss/vite": "catalog:",
@@ -870,7 +871,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.16.2",
"version": "1.17.9",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -912,15 +913,18 @@
"tree-sitter-bash",
],
"patchedDependencies": {
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.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",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@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",
},
"overrides": {
"@opentui/core": "catalog:",
@@ -931,9 +935,9 @@
},
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@effect/opentelemetry": "4.0.0-beta.74",
"@effect/platform-node": "4.0.0-beta.74",
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
"@effect/opentelemetry": "4.0.0-beta.83",
"@effect/platform-node": "4.0.0-beta.83",
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
@@ -944,15 +948,17 @@
"@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.4",
"@pierre/diffs": "1.1.0-beta.18",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"@shikijs/stream": "4.2.0",
"@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@tailwindcss/vite": "4.1.11",
"@tanstack/solid-virtual": "3.13.28",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.13",
@@ -967,25 +973,24 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.74",
"effect": "4.0.0-beta.83",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"luxon": "3.6.1",
"marked": "17.0.1",
"marked-shiki": "1.2.1",
"opentui-spinner": "0.0.6",
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
"remend": "1.3.0",
"semver": "7.7.4",
"shiki": "3.20.0",
"shiki": "4.2.0",
"solid-js": "1.9.10",
"solid-list": "0.3.0",
"sst": "4.13.1",
"tailwindcss": "4.1.11",
"typescript": "5.8.2",
"ulid": "3.0.1",
"virtua": "0.49.1",
"vite": "7.1.4",
"vite-plugin-solid": "2.11.10",
"zod": "4.1.8",
@@ -1013,7 +1018,7 @@
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/openai": "3.0.67", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-PsSh7a6qW+3kQXPs1kD4wDwuZby0t1PIaB6j/1aMKmPFJ5LxcIcULLMF/bjITLt5o/8lc0t6TXIwG0zlhH7uZw=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="],
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
@@ -1281,6 +1286,8 @@
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.0", "", {}, "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA=="],
"@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.12.0", "", { "dependencies": { "@bufbuild/protobuf": "2.12.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-ORlDITp8AFUXzIhLRoMCG+ud+D3MPKWb5HQXBoskMMnjeyEjE1H1qLonVNPyOr8lkx3xSfYUo8a0dvOZJVAzow=="],
@@ -1321,19 +1328,19 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.74", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.74" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-flpyqLPyr+THSe6ZCGRZl6hi+FqxbIXNSkslKGiRJAjbPabam9mSp7R3aC8biIMt6xE4Fd0LNfo4p2GplUkm2Q=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.83", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.83" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-cPfCfp/ghu0itbX6Dqjdr4N0rbjng5ON4sUpnLHV5JJySG8zZpWmuOZLWIrfrNKT2ctYR1BYmp1aYCgkItaJLw=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.74", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.74", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74", "ioredis": "^5.7.0" } }, "sha512-/W16mKqxvhWINLjufzc0log1sl57exXQfwd+em398/zKCbmU3S7snXTDMN6w0ju2TtgK35qrsoGBXEochij6Sg=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.83", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.83", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83", "ioredis": "^5.7.0" } }, "sha512-RmpVGu/+X/Bif3/g1Rzj8oFzTOknoVB3yHCa0b179vytPpKe+Kj9ZwKNcAnKWqHUDkbSPBq1Ca60mvOHr2/+LQ=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.74", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-C6C2hXixNcZXLaFF2u7B/FtOsqpdY7luaPuiGFBJza0P7EnYDkwaT3kB6lv7l/qctmkADc24qOsSCWIKRbC4jg=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.83", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-+yr/+PJmKTgmJq1QOINSBPgLu7Cjc4CZcotBXnGjyDEizOmimFgTkN2B8PBJAKIKUWYWfobjXqC+58/VhhPKAw=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-RVMRVY7NhSoAp9cAAyy4TT6dt6NNZjOpWeqticoho9HNBukxQSUcu/kjcz4Iq9eoQfXadmepu8kZqtdZULM/fg=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.83", "", { "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-6OaxLsWffxkh9pXYUSyj/AxjVb9URY2rG9U6atjxClWy30Jx77R9Pm3Rrc7cQ63kQurePavEw1bQbzQ/SILiQQ=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
"@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],
"@electron/get": ["@electron/get@5.0.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA=="],
"@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="],
@@ -1445,23 +1452,23 @@
"@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="],
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-isGuuEbAo7D6psAllm4+TRONxmDfhlmm548IjsG5hEH4I/pwTTTtrRg4lpMDwQ/cD5I3kEL2KVEYdlwuyFod8w=="],
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xyivu2xB++O5xXDx5Qm50JsU2aXt8YgXlGVhH/HE7UMYDrE6L6f1RYdYs8Y0bn0D3D0+bFBrN5ELPszK9E4Wbw=="],
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vJMyCHtE5/CqCmvH7kEDSkUK9/YImoGZuIrRd6yLBjpSTtwyr0QIYjXDsFSj8a4eyxP3ieZWBw9z+uekPZ4YHw=="],
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xLooAhCnTDCipPSMMZz7kGF3lhRHx6aP5fb6DJ0Ipyw/w/UWJb+xITJFszUl/QnIBoJ/qjDc93/FZMo1dk6gVA=="],
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bapVTzIJZ40WmGYpAN+X3hIOqeynNTH1WPTp6S2pDMj6WQIG0lO4zWboNRAhVxIdsBq7vJwiBm4BKN+8Wp4wzg=="],
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw=="],
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-opdtJbCmDB/SjHx+IaM6DF6UpYUZ8saXbwiAHamqg8ywhBWQoGzTo66BBwbaf6kd7sv7hJsYrUVBqLJhZGfL4A=="],
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA=="],
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-74kucsnuCsp0daZQGtg0YYJL8h8ypt/efzSQjEuja2GPLdZrW9zVO1p+EWP9FZIt0bAf1o71W3PWjSEa3dTLUQ=="],
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q=="],
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-zgbzi24qWaE1l8bFweApM8Zd1ymxfP5tf9yX9k+PqmOGdGQhGWwbWTxB6UCUu+BiLPd+78Lxzp4oIBoSsZzejA=="],
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w=="],
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2ZB3LgEXWY0BJVpN6zr2JeuGYQbOZhNVZYYkKGY9g48L/nUuuB2X1HzQTLQ0zPipmFoPG7dUFlTjl+qmQhJPRw=="],
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ=="],
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.3", "", { "os": "win32", "cpu": "x64" }, "sha512-K6PycT3FluRUEtOqsySbq8oHxP8XeyvdWtnxMlnaSSLc5LKlWg3CKvc+kxfq7UkpySA9LlPk+Qp/C1IvJ890QA=="],
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Yd2Eyxj+slWv+0QDW9/xBpu9FXq+hwD0rXQD5184/88d+xwWCLKhEP2w8I6OO9XCg+kLT79UJb+k0WwXUtBtMw=="],
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.3", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.3", "@ff-labs/fff-bin-darwin-x64": "0.9.3", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.3", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.3", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.3", "@ff-labs/fff-bin-linux-x64-musl": "0.9.3", "@ff-labs/fff-bin-win32-arm64": "0.9.3", "@ff-labs/fff-bin-win32-x64": "0.9.3" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-PPSsmSf1+xD/8eLelBDYFcmlmQUPRCm+GO4K/PgtuLtLu0CWsoxyStykgjw+0GP3bTUVNdHK1FYwESk0hmY6lg=="],
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.4", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.4", "@ff-labs/fff-bin-darwin-x64": "0.9.4", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-x64-musl": "0.9.4", "@ff-labs/fff-bin-win32-arm64": "0.9.4", "@ff-labs/fff-bin-win32-x64": "0.9.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-7HUraaK/g5dStAnuKAuzsXVOQvqqX0ylo5G+DxYwsCjCDc42bjoEAAHqz/3Sn3raUNw97KMoz87XR9QyrLEfVw=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
@@ -1675,6 +1682,8 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
"@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -2109,9 +2118,21 @@
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
"@pierre/diffs": ["@pierre/diffs@1.1.0-beta.18", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-7ZF3YD9fxdbYsPnltz5cUqHacN7ztp8RX/fJLxwv8wIEORpP4+7dHz1h/qx3o4EW2xUrIhmbM8ImywLasB787Q=="],
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.7.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg=="],
"@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="],
"@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="],
"@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="],
"@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="],
"@pierre/diffs": ["@pierre/diffs@1.2.10", "", { "dependencies": { "@pierre/theme": "1.0.3", "@pierre/theming": "0.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-rPeAmDWarxFVTQpaf4y6wTxjZxU44xKJKoJti2zU21P06DVd9nRHZX+xSIObLB307Qjpaesyb1x/j0z94t7vLw=="],
"@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="],
"@pierre/theming": ["@pierre/theming@0.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A=="],
"@pierre/trees": ["@pierre/trees@1.0.0-beta.4", "", { "dependencies": { "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
@@ -2315,13 +2336,17 @@
"@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="],
"@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="],
"@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="],
"@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="],
"@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="],
"@shikijs/stream": ["@shikijs/stream@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0" }, "peerDependencies": { "react": "^19.0.0", "solid-js": "^1.9.0", "vue": "^3.2.0" }, "optionalPeers": ["react", "solid-js", "vue"] }, "sha512-OaMUUStdIZ+l1GJad9uVACR3Xvgwo4y+RmEuDMU62cgFMMg1IBCaIFmvzAR2HiCpGtwoc/qPfpNnP+ivgrPXZg=="],
"@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="],
"@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="],
@@ -2343,7 +2368,7 @@
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
"@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="],
@@ -2565,6 +2590,10 @@
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
@@ -2769,6 +2798,8 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/mocker": ["@vitest/mocker@4.1.7", "", { "dependencies": { "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA=="],
@@ -2853,7 +2884,7 @@
"app-builder-bin": ["app-builder-bin@5.0.0-alpha.12", "", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="],
"app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="],
"app-builder-lib": ["app-builder-lib@26.15.2", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.2", "electron-builder-squirrel-windows": "26.15.2" } }, "sha512-3mYfKOjr/ZY7gFESOcq8kylBMgGPpmlQYnpBVit4p6zIg0t/8bkWBILdMMtnjFyN2jllyBf225T8dLlz3D6oBQ=="],
"archiver": ["archiver@7.0.1", "", { "dependencies": { "archiver-utils": "^5.0.2", "async": "^3.2.4", "buffer-crc32": "^1.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^1.1.2", "tar-stream": "^3.0.0", "zip-stream": "^6.0.1" } }, "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ=="],
@@ -2881,12 +2912,16 @@
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
"asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="],
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg=="],
"astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
"astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
@@ -2917,6 +2952,8 @@
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
"aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="],
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"axe-core": ["axe-core@4.11.4", "", {}, "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA=="],
@@ -2975,6 +3012,8 @@
"blob-to-buffer": ["blob-to-buffer@1.2.9", "", {}, "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA=="],
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
"body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
"bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="],
@@ -3007,9 +3046,9 @@
"buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="],
"builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="],
"builder-util": ["builder-util@26.15.0", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-dUx+HxVbiNsNQ4mGe1PyoC/tBmsHwBNDLdBuqWCj+rhHFE9lHgrXiGYKAM1uNlznhAaUSyMlms84VeSSr3gOBA=="],
"builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
"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=="],
@@ -3021,6 +3060,8 @@
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="],
"c12": ["c12@3.3.3", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.3", "exsolve": "^1.0.8", "giget": "^2.0.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
@@ -3133,7 +3174,7 @@
"condense-newlines": ["condense-newlines@0.2.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-whitespace": "^0.3.0", "kind-of": "^3.0.2" } }, "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg=="],
"conf": ["conf@14.0.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^9.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.4.0" } }, "sha512-L6BuueHTRuJHQvQVc6YXYZRtN5vJUtOdCTLn0tRYYV5azfbAFcPghB5zEE40mVrV6w7slMTqUfkDomutIK14fw=="],
"conf": ["conf@15.1.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="],
"confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
@@ -3153,7 +3194,7 @@
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
"core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
@@ -3281,7 +3322,7 @@
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dmg-builder": ["dmg-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="],
"dmg-builder": ["dmg-builder@26.15.2", "", { "dependencies": { "app-builder-lib": "26.15.2", "builder-util": "26.15.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-fMkjRqKyPtsz4Kzu/qGP0BGjqzMCIgp+/7kw/u6YH6lvn/8hvL3c0TXhoFayBoYdpPCnEinnCHztd4bW7/jetA=="],
"dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="],
@@ -3315,6 +3356,8 @@
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
@@ -3323,13 +3366,13 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
"effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@41.2.1", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-teeRThiYGTPKf/2yOW7zZA1bhb91KEQ4yLBPOg7GxpmnkLFLugKgQaAKOrCgdzwsXh/5mFIfmkm+4+wACJKwaA=="],
"electron": ["electron@42.3.3", "", { "dependencies": { "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-0MwYp9wTb7TrtTalOYqeW+suqd9T/Znstr/nDLKqFGIjHdBZX339guo3mQqTPURRZ/UQmYM4uMpzKpI5wLptfQ=="],
"electron-builder": ["electron-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw=="],
"electron-builder": ["electron-builder@26.15.2", "", { "dependencies": { "app-builder-lib": "26.15.2", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-veKM9+dCljaC5A74Pwc0ZWQ9arOHREXWh9hUIf8NGg49ch7x+IB4QhbMzIrV5ONZIXM2OEkaxW11cAPjPtoi4A=="],
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA=="],
@@ -3341,13 +3384,13 @@
"electron-log": ["electron-log@5.4.4", "", {}, "sha512-istWgaXjBfURBSS8LWVW9C3jsc6+ac+tY1lXrQEOTp0lVj+a4OlO1Tmqb36GgnEUDv92DGC9VI1HNXwJinWpgA=="],
"electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="],
"electron-publish": ["electron-publish@26.15.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-BMgMHOyexWn0UnOC+Afffw0DMrr0yfLp4U8YsLXwoJ3Da7LS7WUnz21teYZqO0gaApE1KgsjREWmbPqvF5JcPg=="],
"electron-store": ["electron-store@10.1.0", "", { "dependencies": { "conf": "^14.0.0", "type-fest": "^4.41.0" } }, "sha512-oL8bRy7pVCLpwhmXy05Rh/L6O93+k9t6dqSw0+MckIc3OmCTZm6Mp04Q4f/J0rtu84Ky6ywkR8ivtGOmrq+16w=="],
"electron-store": ["electron-store@11.0.2", "", { "dependencies": { "conf": "^15.0.2", "type-fest": "^5.0.1" } }, "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.364", "", {}, "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw=="],
"electron-updater": ["electron-updater@6.8.3", "", { "dependencies": { "builder-util-runtime": "9.5.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ=="],
"electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="],
"electron-vite": ["electron-vite@5.0.0", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/plugin-transform-arrow-functions": "^7.27.1", "cac": "^6.7.14", "esbuild": "^0.25.11", "magic-string": "^0.30.19", "picocolors": "^1.1.1" }, "peerDependencies": { "@swc/core": "^1.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@swc/core"], "bin": { "electron-vite": "bin/electron-vite.js" } }, "sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ=="],
@@ -3363,6 +3406,8 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"engine.io-client": ["engine.io-client@6.6.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg=="],
@@ -3373,7 +3418,7 @@
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
@@ -3603,7 +3648,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.8.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-KwHASXkHtDcgrzTXZVp9Dyx6t8m9nK0R2fCm47MWcxxQ1kOBt3f2LZugtu1kOby8i4Sbd+kvBSYM66PGkDclng=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.9.3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-lWo6b6es5+k9iXaDIvE9ECzyK4zfEza4+dQ5FN8SJpEuVRi3ZBCpHIOTa32QoYEDCBaiPh+tcyca86PfNodmlg=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -3883,6 +3928,12 @@
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
"iterate-iterator": ["iterate-iterator@1.0.2", "", {}, "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw=="],
"iterate-value": ["iterate-value@1.0.2", "", { "dependencies": { "es-get-iterator": "^1.0.2", "iterate-iterator": "^1.0.1" } }, "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ=="],
@@ -4051,6 +4102,8 @@
"magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="],
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
"make-fetch-happen": ["make-fetch-happen@15.0.6", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="],
"markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="],
@@ -4295,6 +4348,8 @@
"node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="],
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
"node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="],
"node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="],
@@ -4363,13 +4418,13 @@
"opencode": ["opencode@workspace:packages/opencode"],
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.1", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-1EMZHdbADLMVaTVLQ6C/V8uVMDr6MP++osj2lmOecowtn46AafP/w6ADkV4AN/ddjA1rob5cWpMuf/iME6DI6A=="],
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.1.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-ZCDYaY0V8Se6hOH2tqZqqcskrd0xLTgfiGhU0J1igkUP52oFtN9eSwxOPLT0ctvNXUq8b+zOmJ4sskAQoC/IUA=="],
"opencode-poe-auth": ["opencode-poe-auth@0.0.1", "", { "dependencies": { "open": "^10.0.0", "poe-oauth": "*" }, "peerDependencies": { "@opencode-ai/plugin": "*" } }, "sha512-cXqTlS6AXHzo1oBdosnxbT47ZJEZ9WXn050X8Re6wZ1vaNnTpB/l2fMQt90evT7RBK0fB8UjXQUDMKyd7bbiqg=="],
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
"opentui-spinner": ["opentui-spinner@0.0.6", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.1.49", "@opentui/react": "^0.1.49", "@opentui/solid": "^0.1.49", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-xupLOeVQEAXEvVJCvHkfX6fChDWmJIPHe5jyUrVb8+n4XVTX8mBNhitFfB9v2ZbkC1H2UwPab/ElePHoW37NcA=="],
"opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="],
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
@@ -4489,6 +4544,8 @@
"pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
"pkijs": ["pkijs@3.4.0", "", { "dependencies": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw=="],
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
@@ -4521,6 +4578,10 @@
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
"preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="],
"preact-render-to-string": ["preact-render-to-string@6.6.5", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA=="],
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
"pretty": ["pretty@2.0.0", "", { "dependencies": { "condense-newlines": "^0.2.1", "extend-shallow": "^2.0.1", "js-beautify": "^1.6.12" } }, "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w=="],
@@ -4571,6 +4632,10 @@
"pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="],
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
@@ -4799,7 +4864,7 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
"shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="],
"shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],
@@ -4977,6 +5042,8 @@
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
@@ -5171,6 +5238,8 @@
"unzip-stream": ["unzip-stream@0.3.4", "", { "dependencies": { "binary": "^0.3.0", "mkdirp": "^0.5.1" } }, "sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw=="],
"unzipper": ["unzipper@0.12.3", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "^11.2.0", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA=="],
"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=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
@@ -5205,8 +5274,6 @@
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"virtua": ["virtua@0.49.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg=="],
"vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="],
"vite-plugin-dynamic-import": ["vite-plugin-dynamic-import@1.6.0", "", { "dependencies": { "acorn": "^8.12.1", "es-module-lexer": "^1.5.4", "fast-glob": "^3.3.2", "magic-string": "^0.30.11" } }, "sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg=="],
@@ -5261,6 +5328,8 @@
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
"webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"webpack-sources": ["webpack-sources@3.5.0", "", {}, "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ=="],
@@ -5385,7 +5454,9 @@
"@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@ai-sdk/anthropic/@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/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@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-sdk/azure/@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=="],
@@ -5451,6 +5522,8 @@
"@astrojs/markdown-remark/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"@astrojs/markdown-remark/shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
"@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/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
@@ -5627,9 +5700,7 @@
"@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@electron/get/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="],
"@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
@@ -5743,8 +5814,6 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/core/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -5759,8 +5828,6 @@
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@opencode-ai/tui/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
@@ -5779,8 +5846,6 @@
"@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="],
@@ -5797,13 +5862,17 @@
"@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/langs/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@shikijs/langs/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/themes/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/stream/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
"@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
@@ -5829,6 +5898,10 @@
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
"@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
"@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
"@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -5857,6 +5930,10 @@
"@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="],
"@vitest/coverage-v8/magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
"@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
@@ -5899,6 +5976,8 @@
"archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
"astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
"astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
@@ -5907,6 +5986,8 @@
"astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"astro/shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
"astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
"astro/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
@@ -5937,9 +6018,7 @@
"condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
"conf/dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="],
"conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"conf/dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="],
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
@@ -5951,8 +6030,6 @@
"dir-compare/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"dmg-builder/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"dmg-license/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=="],
@@ -5961,6 +6038,8 @@
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
"duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"editorconfig/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
@@ -5971,16 +6050,24 @@
"electron-builder/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=="],
"electron-builder-squirrel-windows/app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="],
"electron-builder-squirrel-windows/builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="],
"electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
"electron-store/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="],
"electron-updater/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"electron-updater/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
"encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"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=="],
"esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -6017,6 +6104,8 @@
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"got/@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
"html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"html-minifier-terser/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
@@ -6027,6 +6116,8 @@
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
"istanbul-reports/html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
@@ -6063,6 +6154,8 @@
"nitro/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="],
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"node-gyp/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
"node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -6073,8 +6166,6 @@
"nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -6083,6 +6174,8 @@
"opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="],
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
@@ -6099,6 +6192,8 @@
"pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
@@ -6137,9 +6232,9 @@
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
"shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -6185,12 +6280,16 @@
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
"unzipper/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
"venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
"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=="],
@@ -6277,6 +6376,18 @@
"@astrojs/markdown-remark/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="],
"@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="],
"@astrojs/markdown-remark/shiki/@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="],
"@astrojs/markdown-remark/shiki/@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="],
"@astrojs/markdown-remark/shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="],
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
@@ -6347,8 +6458,6 @@
"@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
@@ -6575,6 +6684,8 @@
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"@shikijs/stream/@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
@@ -6593,10 +6704,16 @@
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
"@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@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=="],
"@vitest/coverage-v8/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="],
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
@@ -6619,6 +6736,8 @@
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -6637,6 +6756,18 @@
"astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"astro/shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"astro/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="],
"astro/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="],
"astro/shiki/@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="],
"astro/shiki/@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="],
"astro/shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"astro/unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"astro/unstorage/h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="],
@@ -6657,6 +6788,8 @@
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"conf/dot-prop/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
@@ -6667,8 +6800,38 @@
"dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"duplexer2/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"duplexer2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"editorconfig/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
"electron-builder-squirrel-windows/app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
"electron-builder-squirrel-windows/app-builder-lib/builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
"electron-builder-squirrel-windows/app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
"electron-builder-squirrel-windows/app-builder-lib/dmg-builder": ["dmg-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="],
"electron-builder-squirrel-windows/app-builder-lib/electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="],
"electron-builder-squirrel-windows/app-builder-lib/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
"electron-builder-squirrel-windows/app-builder-lib/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"electron-builder-squirrel-windows/app-builder-lib/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"electron-builder-squirrel-windows/app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"electron-builder-squirrel-windows/builder-util/builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
"electron-builder-squirrel-windows/builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"electron-builder-squirrel-windows/builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"electron-builder/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=="],
"electron-builder/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=="],
@@ -6699,8 +6862,6 @@
"js-beautify/nopt/abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="],
"lazystream/readable-stream/core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"lazystream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
@@ -6711,6 +6872,8 @@
"motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -6741,6 +6904,8 @@
"unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -6995,6 +7160,26 @@
"editorconfig/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"electron-builder-squirrel-windows/app-builder-lib/dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"electron-builder-squirrel-windows/app-builder-lib/electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"electron-builder-squirrel-windows/app-builder-lib/electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
"electron-builder-squirrel-windows/app-builder-lib/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"electron-builder-squirrel-windows/app-builder-lib/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"electron-builder-squirrel-windows/app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"electron-builder-squirrel-windows/builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"electron-builder/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"electron-builder/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -7081,6 +7266,8 @@
"babel-plugin-module-resolver/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"electron-builder/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"electron-builder/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
[test]
root = "./do-not-run-tests-from-root"
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-2MWaL8yxBR7Lw7XH8TY2V11fqro3Ncn0+q/iEpOiX6g=",
"aarch64-linux": "sha256-IoiotWekTroxCFOjQPMteWkY26O+hAZnQtxHY/svJxQ=",
"aarch64-darwin": "sha256-hky+dzAQBENArDz7Hr1kfj+bbWjTySiHGK2LrRBKt2k=",
"x86_64-darwin": "sha256-tIO18GIjboqv0rWlsbhQ0JNSmzTFswVNUHuE7ZHFxzo="
"x86_64-linux": "sha256-g0tDvRf7MErZ1PEeUazEYi492ZHiRT8kYv3bPdkss/I=",
"aarch64-linux": "sha256-6sKgf3ftbIqlPxlFkoPzoWPsJp3IwXD+H3Y6g874xmk=",
"aarch64-darwin": "sha256-Se/Nls/KlkuK2ysDQ9DeAzSaX3NsL2iDdf/dsv2GIXc=",
"x86_64-darwin": "sha256-V9MCkqnvQ1nkD2PaaTfNFKkBZGymj6KxrSAK6+DTF8Y="
}
}
+15 -10
View File
@@ -30,9 +30,9 @@
"packages/slack"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-beta.74",
"@effect/platform-node": "4.0.0-beta.74",
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
"@effect/opentelemetry": "4.0.0-beta.83",
"@effect/platform-node": "4.0.0-beta.83",
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
@@ -42,6 +42,8 @@
"@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.4",
"@tanstack/solid-virtual": "3.13.28",
"@shikijs/stream": "4.2.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -51,15 +53,15 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.1.0-beta.18",
"opentui-spinner": "0.0.6",
"@pierre/diffs": "1.2.10",
"opentui-spinner": "0.0.7",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.74",
"effect": "4.0.0-beta.83",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
@@ -76,10 +78,9 @@
"zod": "4.1.8",
"remeda": "2.26.0",
"sst": "4.13.1",
"shiki": "3.20.0",
"shiki": "4.2.0",
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
"virtua": "0.49.1",
"vite": "7.1.4",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
@@ -140,14 +141,18 @@
"@types/node": "catalog:"
},
"patchedDependencies": {
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.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"
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch"
}
}
+5
View File
@@ -1,3 +1,8 @@
## Priorities
- Prioritise, in this order: stability, simplicity, performance.
- Before changing session or timeline code, record a production benchmark baseline and compare it after the change.
## Debugging
- NEVER try to restart the app, or the server process, EVER.
+13
View File
@@ -0,0 +1,13 @@
- Prioritize stability, then simplicity, then measurement overhead.
- Use Playwright for scenario control, isolation, and completion checks.
- Use Chrome Performance traces for generic browser profiling.
- Use Electron `contentTracing` for packaged multi-process profiling.
- Keep custom probes only for product-specific measurements.
- Do not duplicate measurements across the harness, probes, and traces.
- Run benchmarks serially to avoid cross-test contention.
- Run benchmarks against production builds.
- Keep detailed profiling opt-in when it changes workload behavior.
- Preserve raw diagnostic data or use lossless representations.
- Do not enforce machine-dependent performance thresholds.
- Assert scenario completion and metric collection only.
- Keep normal test discovery free of manual benchmarks.
+77
View File
@@ -0,0 +1,77 @@
# Manual app performance suite
The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially.
Run the suite explicitly from `packages/app`:
```sh
bun run test:bench
```
PowerShell:
```powershell
$env:PLAYWRIGHT_WORKERS = "1"
bun run test:bench
```
The suite contains:
- cold and hot session-tab timing
- cached session repaint and mutation tracing
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle.
New benchmarks should look like normal Playwright tests:
```ts
import { benchmark, expect } from "../benchmark"
benchmark("measures one interaction", async ({ page, report }) => {
// Only scenario-specific setup and interaction belong here.
report({ durationMs: 42 })
})
```
The fixture requires every benchmark to call `report()`, automatically names and closes traces, captures navigation history, attaches that history when a test fails, and emits metrics as a consistent `BENCHMARK` JSON line.
```text
BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}}
```
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows.
This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer).
These Playwright benchmarks profile the shared app renderer in Chromium. A future packaged Electron benchmark that needs main-process and multi-process attribution should use Electron's official [`contentTracing`](https://www.electronjs.org/docs/latest/api/content-tracing/) API rather than extending this renderer harness with bespoke process instrumentation.
CPU and high-volume visual profiling are disabled by default. Set `TIMELINE_CPU_PROFILE=1` to enable both, or additionally set `TIMELINE_VISUAL_PROFILE=0` for CPU-only profiling.
The streaming scenario's 30x CPU throttle is a deterministic stress profile, not a simulated end-user device.
Benchmarks do not assert machine-dependent performance budgets. Streaming processes 160 deltas by default and reports renderer-observed completion time, throughput, RAF callback-gap distributions, frame-budget equivalents, and long tasks through final geometry settlement. Delta count and delivery batch are included in result context when overridden. These are main-thread callback diagnostics, not compositor presentation or dropped-frame measurements. Visual-only and geometry metrics are `null` when their probes are disabled. Tab metrics describe sampled DOM observations. Assertions verify scenario and metric collection completion. Repeated repaint states are run-length grouped, but every original observation timestamp is retained alongside raw mutation batches and layout shifts.
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
## Chrome traces
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
```sh
OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-performance-traces \
bunx playwright test --config e2e/performance/playwright.config.ts \
timeline/session-tab-switch-benchmark.spec.ts
```
The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies:
Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss.
```sh
bunx devtools-tracing stats <trace-path-from-BENCHMARK_PAGE>
```
INP analysis requires a trace with a supported navigation/interaction insight. Selector statistics require a trace captured with `OPENCODE_PERFORMANCE_SELECTOR_TRACE=1`.
`e2e/performance/playwright.uncapped.config.ts` disables Chromium frame-rate limiting for explicit uncapped diagnostics. Native product benchmarks should use the default Playwright configuration.
+144
View File
@@ -0,0 +1,144 @@
import { expect, test as base, type Browser, type Page, type TestInfo } from "@playwright/test"
import { startChromeTrace } from "./chrome-trace"
type BenchmarkFixtures = {
report: (metrics: Record<string, unknown>, context?: Record<string, unknown>) => void
reportState: { payload?: { metrics: Record<string, unknown>; context: Record<string, unknown> } }
benchmarkResult: void
}
export type PerformancePageDiagnostics = {
navigations: string[]
stop: () => Promise<string | undefined>
}
const pages = new WeakMap<Page, PerformancePageDiagnostics>()
export const benchmark = base.extend<BenchmarkFixtures>({
reportState: async ({}, use) => use({}),
report: async ({ reportState }, use) => {
await use((metrics, context = {}) => {
if (reportState.payload) throw new Error("Benchmark reported metrics more than once")
reportState.payload = { metrics, context }
})
},
benchmarkResult: [
async ({ reportState }, use, testInfo) => {
await use()
const missing = !reportState.payload
console.log(
`BENCHMARK ${JSON.stringify({
schemaVersion: 2,
runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
name: benchmarkName(testInfo),
status: missing ? "failed" : testInfo.status,
expectedStatus: testInfo.expectedStatus,
retry: testInfo.retry,
repeatEachIndex: testInfo.repeatEachIndex,
context: {
project: testInfo.project.name,
platform: process.platform,
...reportState.payload?.context,
},
metrics: reportState.payload?.metrics ?? null,
error: missing ? "Benchmark did not report metrics" : undefined,
})}`,
)
if (missing && testInfo.status === testInfo.expectedStatus)
throw new Error(`Benchmark did not report metrics: ${benchmarkName(testInfo)}`)
},
{ auto: true },
],
page: async ({ page }, use, testInfo) => {
const name = benchmarkName(testInfo)
const diagnostics = await observePerformancePage(page, name)
try {
await use(page)
} finally {
try {
await reportPerformancePage(name, diagnostics, testInfo)
} finally {
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach("performance-navigations", {
body: JSON.stringify(diagnostics.navigations, null, 2),
contentType: "application/json",
})
}
}
}
},
})
function benchmarkName(testInfo: TestInfo) {
return testInfo.titlePath.slice(1).join(" > ")
}
export { expect }
async function observePerformancePage(page: Page, name: string) {
const navigations: string[] = []
const onNavigation = (frame: ReturnType<Page["mainFrame"]>) => {
if (frame === page.mainFrame()) navigations.push(frame.url())
}
page.on("framenavigated", onNavigation)
const stopTrace = await startChromeTrace(page, name).catch((error) => {
page.off("framenavigated", onNavigation)
throw error
})
let stopping: Promise<string | undefined> | undefined
const diagnostics: PerformancePageDiagnostics = {
navigations,
stop() {
page.off("framenavigated", onNavigation)
return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined))
},
}
pages.set(page, diagnostics)
return diagnostics
}
export async function withBenchmarkPage<T>(
browser: Browser,
name: string,
run: (page: Page) => Promise<T>,
testInfo?: TestInfo,
) {
const context = await browser.newContext()
try {
const page = await context.newPage()
const diagnostics = await observePerformancePage(page, name)
try {
return await run(page)
} finally {
await reportPerformancePage(name, diagnostics, testInfo)
}
} finally {
await context.close()
}
}
async function reportPerformancePage(name: string, diagnostics: PerformancePageDiagnostics, testInfo?: TestInfo) {
const trace = await diagnostics.stop()
console.log(
`BENCHMARK_PAGE ${JSON.stringify({
schemaVersion: 2,
runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
name,
test: testInfo ? benchmarkName(testInfo) : undefined,
retry: testInfo?.retry,
repeatEachIndex: testInfo?.repeatEachIndex,
context: {
platform: process.platform,
trace,
selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1",
},
navigations: diagnostics.navigations,
})}`,
)
}
export function benchmarkDiagnostics(page: Page) {
const diagnostics = pages.get(page)
if (!diagnostics) throw new Error("Performance diagnostics are not installed for this page")
return diagnostics
}
@@ -0,0 +1,95 @@
import type { CDPSession, Page } from "@playwright/test"
import path from "node:path"
import { mkdir, open, rename } from "node:fs/promises"
import { Buffer } from "node:buffer"
import { createHash, randomUUID } from "node:crypto"
const categories = [
"-*",
"devtools.timeline",
"v8.execute",
"disabled-by-default-devtools.timeline",
"disabled-by-default-devtools.timeline.frame",
"toplevel",
"blink.console",
"blink.user_timing",
"latencyInfo",
"disabled-by-default-devtools.timeline.stack",
"disabled-by-default-v8.cpu_profiler",
]
export async function startChromeTrace(page: Page, name: string) {
const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR
if (!directory) return
const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1"
const file = await prepareChromeTrace(directory, name, selectors)
const session = await page.context().newCDPSession(page)
try {
await session.send("Tracing.start", {
transferMode: "ReturnAsStream",
traceConfig: {
excludedCategories: categories
.filter((category) => category.startsWith("-"))
.map((category) => category.slice(1)),
includedCategories: [
...categories.filter((category) => !category.startsWith("-")),
...(selectors
? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"]
: []),
],
},
})
} catch (error) {
await Promise.allSettled([session.detach()])
throw error
}
let stopping: Promise<string> | undefined
return () =>
(stopping ??= (async () => {
try {
const complete = new Promise<{ stream?: string; dataLossOccurred: boolean }>((resolve) =>
session.once("Tracing.tracingComplete", resolve),
)
await session.send("Tracing.end")
const result = await complete
if (!result.stream) throw new Error(`Chrome trace stream missing: ${file}`)
const partial = `${file}.partial`
await writeProtocolStream(session, result.stream, partial)
if (result.dataLossOccurred) throw new Error(`Chrome trace lost data; partial capture retained: ${partial}`)
await rename(partial, file)
return file
} finally {
await Promise.allSettled([session.detach()])
}
})())
}
export async function prepareChromeTrace(
directory: string,
name: string,
selectors: boolean,
nonce = randomUUID().slice(0, 8),
) {
await mkdir(directory, { recursive: true })
const run = process.env.OPENCODE_PERFORMANCE_RUN_ID ?? "manual"
const hash = createHash("sha256").update(name).digest("hex").slice(0, 8)
return path.join(
directory,
`${run}-${name.replace(/[^a-zA-Z0-9_-]/g, "-")}-${hash}-${nonce}${selectors ? "-selectors" : ""}.json`,
)
}
async function writeProtocolStream(session: CDPSession, handle: string, file: string) {
const output = await open(file, "wx")
try {
while (true) {
const chunk = await session.send("IO.read", { handle })
await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data)
if (chunk.eof) break
}
} finally {
await Promise.allSettled([output.close(), session.send("IO.close", { handle })])
}
}
@@ -0,0 +1,20 @@
import config from "../../playwright.config"
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
process.env.PLAYWRIGHT_SERVER_PORT = String(port)
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
export default {
...config,
testDir: ".",
testIgnore: "unit/**",
outputDir: "../test-results/performance",
fullyParallel: false,
workers: 1,
reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]],
webServer: {
...config.webServer,
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
reuseExistingServer: false,
},
}
@@ -0,0 +1,13 @@
import config from "./playwright.config"
export default {
...config,
outputDir: "../test-results/performance-uncapped",
reporter: [["html", { outputFolder: "../playwright-report/performance-uncapped", open: "never" }], ["line"]],
use: {
...config.use,
launchOptions: {
args: ["--disable-frame-rate-limit", "--disable-gpu-vsync"],
},
},
}
@@ -0,0 +1,49 @@
import { benchmark, expect } from "../benchmark"
import { expectSessionTitle } from "../../utils/waits"
import { fixture } from "./session-timeline-stress.fixture"
import {
collectCachedRepaintTrace,
compressCachedRepaintTrace,
installCachedRepaintProbe,
waitForCachedRepaintWindow,
} from "./session-tab-repaint-probe"
import { waitForStableTimeline } from "./session-tab-switch-probe"
import {
installStressSessionTabs,
installTimelineSettings,
mockStressTimeline,
stressSessionHref,
} from "./timeline-test-helpers"
benchmark("samples cached session repaint after the click", async ({ page, report }) => {
benchmark.setTimeout(120_000)
await mockStressTimeline(page)
await installStressSessionTabs(page)
await installTimelineSettings(page)
await page.goto(stressSessionHref(fixture.targetID))
await expectSessionTitle(page, fixture.expected.targetTitle)
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await page
.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`)
.first()
.click()
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
await installCachedRepaintProbe(page, {
targetHref: stressSessionHref(fixture.targetID),
destination: fixture.messages[fixture.targetID].map((message) => message.info.id),
source: fixture.messages[fixture.sourceID].map((message) => message.info.id),
last: fixture.expected.targetMessageIDs.at(-1)!,
windowMs: 1_000,
})
await page
.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`)
.first()
.click()
await Promise.all([expectSessionTitle(page, fixture.expected.targetTitle), waitForCachedRepaintWindow(page, 1_000)])
const result = await collectCachedRepaintTrace(page)
report(compressCachedRepaintTrace(result))
expect(result.samples.length).toBeGreaterThan(0)
})
@@ -0,0 +1,251 @@
import type { Page } from "@playwright/test"
type CachedRepaintTrace = {
timeOriginEpochMs: number
startedAtPerformanceMs: number
samples: {
observedAtMs: number
root: number | undefined
scrollTop: number
scrollHeight: number
bottomErrorPx: number | undefined
last: boolean
rows: { key: string | undefined; node: number; top: number; bottom: number }[]
mounted: number
center: string | undefined
destination: string[]
source: string[]
}[]
mutations: { observedAtMs: number; changed: { type: string; node: number }[] }[]
shifts: { occurredAtMs: number; value: number }[]
windowMs: number
running: boolean
stop: () => void
}
export async function installCachedRepaintProbe(
page: Page,
input: { targetHref: string; destination: string[]; source: string[]; last: string; windowMs: number },
) {
await page.evaluate(({ targetHref, destination, source, last, windowMs }) => {
const destinationIDs = new Set(destination)
const sourceIDs = new Set(source)
const nodeIDs = new WeakMap<Node, number>()
let nextNodeID = 1
const id = (node: Node) => {
const current = nodeIDs.get(node)
if (current) return current
nodeIDs.set(node, nextNodeID)
return nextNodeID++
}
const state: CachedRepaintTrace = {
timeOriginEpochMs: performance.timeOrigin,
startedAtPerformanceMs: 0,
samples: [],
mutations: [],
shifts: [],
windowMs,
running: false,
stop: () => {},
}
const recordShifts = (entries: PerformanceEntry[]) => {
if (!state.running) return
state.shifts.push(
...entries
.map((entry) => {
if (
entry.startTime < state.startedAtPerformanceMs ||
entry.startTime > state.startedAtPerformanceMs + state.windowMs
)
return
return {
occurredAtMs: entry.startTime - state.startedAtPerformanceMs,
value: (entry as PerformanceEntry & { value: number }).value,
}
})
.filter((entry): entry is { occurredAtMs: number; value: number } => entry !== undefined),
)
}
const shiftObserver = new PerformanceObserver((entries) => recordShifts(entries.getEntries()))
shiftObserver.observe({ type: "layout-shift" })
const recordMutations = (entries: MutationRecord[]) => {
if (!state.running) return
const observedAtMs = performance.now() - state.startedAtPerformanceMs
if (observedAtMs > state.windowMs) return
const changed = entries.flatMap((entry) => [
...[...entry.addedNodes].map((node) => ({ type: "add", node: id(node) })),
...[...entry.removedNodes].map((node) => ({ type: "remove", node: id(node) })),
])
if (changed.length) state.mutations.push({ observedAtMs, changed })
}
const mutationObserver = new MutationObserver(recordMutations)
mutationObserver.observe(document.documentElement, { childList: true, subtree: true })
state.stop = () => {
recordShifts(shiftObserver.takeRecords())
recordMutations(mutationObserver.takeRecords())
state.running = false
shiftObserver.disconnect()
mutationObserver.disconnect()
}
const sample = () => {
if (!state.running) return
setTimeout(() => {
if (!state.running) return
const observedAtMs = performance.now() - state.startedAtPerformanceMs
if (observedAtMs > state.windowMs) return
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (root) {
const view = root.getBoundingClientRect()
const rows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
.map((element) => ({
key: element.dataset.timelineKey,
node: id(element),
rect: element.getBoundingClientRect(),
}))
.filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
.map((item) => ({
key: item.key,
node: item.node,
top: item.rect.top - view.top,
bottom: item.rect.bottom - view.top,
}))
const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((element) => element.dataset.messageId!)
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
state.samples.push({
observedAtMs,
root: id(root),
scrollTop: root.scrollTop,
scrollHeight: root.scrollHeight,
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
last: messages.includes(last),
rows,
mounted: root.querySelectorAll("[data-timeline-key]").length,
center: document
.elementFromPoint(view.left + view.width / 2, view.top + view.height / 2)
?.textContent?.slice(0, 80),
destination: messages.filter((messageID) => destinationIDs.has(messageID)),
source: messages.filter((messageID) => sourceIDs.has(messageID)),
})
} else {
state.samples.push({
observedAtMs,
root: undefined,
scrollTop: 0,
scrollHeight: 0,
bottomErrorPx: undefined,
last: false,
rows: [],
mounted: 0,
center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
destination: [],
source: [],
})
}
requestAnimationFrame(sample)
}, 0)
}
document.addEventListener(
"click",
(event) => {
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== targetHref) return
state.startedAtPerformanceMs = performance.now()
state.running = true
requestAnimationFrame(sample)
},
{ capture: true, once: true },
)
;(window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash = state
}, input)
}
export function layoutShiftSample(entry: Pick<PerformanceEntry, "startTime"> & { value: number }, started: number) {
if (entry.startTime < started) return
return { occurredAtMs: entry.startTime - started, value: entry.value }
}
export async function waitForCachedRepaintWindow(page: Page, durationMs: number) {
await page.waitForFunction((durationMs) => {
const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash
return !!state?.running && performance.now() - state.startedAtPerformanceMs >= durationMs
}, durationMs)
}
export async function collectCachedRepaintTrace(page: Page) {
return page.evaluate(() => {
const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash!
state.stop()
return state
})
}
export function summarizeCachedRepaintTrace(trace: CachedRepaintTrace) {
const roots = trace.samples.map((sample) => sample.root)
const bottomErrors = trace.samples.flatMap((sample) =>
sample.bottomErrorPx === undefined ? [] : [Math.abs(sample.bottomErrorPx)],
)
const category = (sample: CachedRepaintTrace["samples"][number]) => {
if (sample.source.length) return "source"
if (sample.root === undefined || sample.rows.length === 0) return "blank"
if (!sample.destination.length) return "unknown"
if (sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) return "correct"
return "wrongDestination"
}
return {
samples: trace.samples.length,
durationMs: trace.samples.at(-1)?.observedAtMs ?? 0,
firstSampleObservedMs: trace.samples[0]?.observedAtMs,
firstSampleCorrect: trace.samples[0] ? category(trace.samples[0]) === "correct" : false,
blankSamples: trace.samples.filter((sample) => category(sample) === "blank").length,
sourceSamples: trace.samples.filter((sample) => category(sample) === "source").length,
wrongDestinationSamples: trace.samples.filter((sample) => category(sample) === "wrongDestination").length,
unknownSamples: trace.samples.filter((sample) => category(sample) === "unknown").length,
rootChanges: roots.slice(1).filter((root, index) => root !== roots[index]).length,
mountedMin: trace.samples.length ? Math.min(...trace.samples.map((sample) => sample.mounted)) : 0,
mountedMax: Math.max(...trace.samples.map((sample) => sample.mounted)),
maxBottomErrorPx: Math.max(0, ...bottomErrors),
mutationBatches: trace.mutations.length,
addedNodes: trace.mutations.reduce(
(sum, batch) => sum + batch.changed.filter((change) => change.type === "add").length,
0,
),
removedNodes: trace.mutations.reduce(
(sum, batch) => sum + batch.changed.filter((change) => change.type === "remove").length,
0,
),
layoutShiftValueSum: trace.shifts.reduce((sum, shift) => sum + shift.value, 0),
maxLayoutShiftValue: Math.max(0, ...trace.shifts.map((shift) => shift.value)),
}
}
export function compressCachedRepaintTrace(trace: CachedRepaintTrace) {
const samples: {
observedAtMs: number[]
state: Omit<CachedRepaintTrace["samples"][number], "observedAtMs">
}[] = []
for (const sample of trace.samples) {
const { observedAtMs, ...state } = sample
const previous = samples.at(-1)
if (previous && JSON.stringify(previous.state) === JSON.stringify(state)) {
previous.observedAtMs.push(observedAtMs)
continue
}
samples.push({ observedAtMs: [observedAtMs], state })
}
return {
timeOriginEpochMs: trace.timeOriginEpochMs,
startedAtPerformanceMs: trace.startedAtPerformanceMs,
windowMs: trace.windowMs,
summary: summarizeCachedRepaintTrace(trace),
samples,
mutations: trace.mutations,
shifts: trace.shifts,
}
}
@@ -0,0 +1,79 @@
import type { Page } from "@playwright/test"
import { expectSessionTitle } from "../../utils/waits"
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
import { fixture } from "./session-timeline-stress.fixture"
import { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
type Result = Awaited<ReturnType<typeof measureSessionSwitch>>
benchmark("benchmarks cold and hot session tab switching", async ({ browser, report }, testInfo) => {
benchmark.setTimeout(180_000)
const results = { cold: [] as Result[], hot: [] as Result[] }
for (const mode of ["cold", "hot"] as const) {
for (let run = 0; run < 5; run++) {
results[mode].push(
await withBenchmarkPage(browser, `session-tab-switch-${mode}-${run}`, (page) => trial(page, mode), testInfo),
)
}
}
report({ results, summary: summarize(results) })
})
async function trial(page: Page, mode: "cold" | "hot") {
await mockStressTimeline(page)
await installStressSessionTabs(page)
if (mode === "hot") {
await page.goto(stressSessionHref(fixture.targetID))
await expectSessionTitle(page, fixture.expected.targetTitle)
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
} else {
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
}
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id)
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id)
const lastID = fixture.expected.targetMessageIDs.at(-1)!
const href = stressSessionHref(fixture.targetID)
const result = await measureSessionSwitch(page, {
destinationIDs,
sourceIDs,
lastID,
href,
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
})
return result
}
function summarize(results: Record<"cold" | "hot", Result[]>) {
const stats = (values: (number | null)[]) => {
const sorted = values.filter((value): value is number => value !== null).sort((a, b) => a - b)
return {
min: sorted[0] ?? null,
median: sorted[Math.floor(sorted.length / 2)] ?? null,
max: sorted.at(-1) ?? null,
missing: values.length - sorted.length,
}
}
return Object.fromEntries(
Object.entries(results).map(([mode, values]) => [
mode,
{
firstDestinationObservedMs: stats(values.map((value) => value.firstDestinationObservedMs)),
firstCorrectObservedMs: stats(values.map((value) => value.firstCorrectObservedMs)),
stableObservedMs: stats(values.map((value) => value.stableObservedMs)),
},
]),
)
}
async function switchSession(page: Page, sessionID: string, title: string) {
const href = stressSessionHref(sessionID)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
await expectSessionTitle(page, title)
}
@@ -0,0 +1,46 @@
export type SessionSwitchSample = {
observedAtMs: number
destination: string[]
source: string[]
hasVisibleRows: boolean
last: boolean
bottomErrorPx?: number
}
export function classifySessionSwitch(samples: SessionSwitchSample[]) {
const firstDestination = samples.findIndex((sample) => sample.destination.length > 0)
const firstCorrect = samples.findIndex(isCorrectDestination)
const stable = samples.findIndex((_, index) => isStableSessionSwitch(samples.slice(index, index + 3)))
return {
firstDestinationObservedMs: samples[firstDestination]?.observedAtMs ?? null,
firstCorrectObservedMs: samples[firstCorrect]?.observedAtMs ?? null,
stableObservedMs: samples[stable + 2]?.observedAtMs ?? null,
wrongDestinationSamples: samples
.slice(firstDestination)
.filter((sample) => sample.destination.length > 0 && !sample.last).length,
blankSamples: samples.filter((sample) => !sample.hasVisibleRows).length,
unknownSamples: samples.filter(
(sample) => sample.hasVisibleRows && sample.destination.length === 0 && sample.source.length === 0,
).length,
sourceSamples: samples.filter((sample) => sample.source.length > 0).length,
}
}
export function isCorrectDestination(sample: SessionSwitchSample) {
return (
sample.destination.length > 0 &&
sample.source.length === 0 &&
sample.last &&
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1
)
}
export function isStableSessionSwitch(samples: SessionSwitchSample[]) {
return samples.length === 3 && samples.every(isCorrectDestination)
}
export function isStableDestination(samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[]) {
return (
samples.length === 3 && samples.every((sample) => sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
)
}
@@ -0,0 +1,152 @@
import { expect, type Page } from "@playwright/test"
import { classifySessionSwitch, isStableDestination, type SessionSwitchSample } from "./session-tab-switch-metrics"
type SessionSwitchProbe = {
samples: SessionSwitchSample[]
stop: () => void
}
async function installSessionSwitchProbe(
page: Page,
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
) {
await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => {
const destination = new Set(destinationIDs)
const source = new Set(sourceIDs)
const samples: SessionSwitchSample[] = []
let started: number | undefined
let running = true
const sample = () => {
if (!running || started === undefined) return
setTimeout(() => {
if (!running || started === undefined) return
const observedAtMs = performance.now() - started
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (root) {
const view = root.getBoundingClientRect()
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((element) => element.dataset.messageId!)
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
samples.push({
observedAtMs,
destination: visible.filter((id) => destination.has(id)),
source: visible.filter((id) => source.has(id)),
hasVisibleRows,
last: visible.includes(lastID),
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
})
} else {
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false })
}
requestAnimationFrame(sample)
}, 0)
}
document.addEventListener(
"click",
(event) => {
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
requestAnimationFrame(sample)
},
{ capture: true, once: true },
)
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
samples,
stop: () => {
running = false
},
}
}, input)
}
async function waitForStableSessionSwitch(page: Page) {
await page.waitForFunction(() => {
const samples = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.samples
if (!samples) return false
return samples.some((_, index) => {
const stable = samples.slice(index, index + 3)
return (
stable.length === 3 &&
stable.every(
(sample) =>
sample.destination.length > 0 &&
sample.source.length === 0 &&
sample.last &&
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
)
)
})
})
}
async function collectSessionSwitchResult(page: Page) {
const samples = await page.evaluate(() => {
const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe!
probe.stop()
return probe.samples
})
return classifySessionSwitch(samples)
}
export async function measureSessionSwitch(
page: Page,
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise<void> },
) {
const { switch: run, ...probe } = input
await installSessionSwitchProbe(page, probe)
await run()
await waitForStableSessionSwitch(page)
return collectSessionSwitchResult(page)
}
export async function waitForStableTimeline(page: Page, lastID: string) {
const samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[] = []
await expect
.poll(
async () => {
samples.push(
await page.evaluate(
(lastID) =>
new Promise<Pick<SessionSwitchSample, "last" | "bottomErrorPx">>((resolve) => {
requestAnimationFrame(() =>
setTimeout(() => {
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (!root) {
resolve({ last: false })
return
}
const view = root.getBoundingClientRect()
const last = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].some((element) => {
if (element.dataset.messageId !== lastID) return false
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
const spacer = root
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect()
resolve({ last, bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined })
}, 0),
)
}),
lastID,
),
)
return isStableDestination(samples.slice(-3))
},
{ timeout: 30_000, intervals: [0] },
)
.toBe(true)
}
@@ -0,0 +1,488 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
import { expect } from "../benchmark"
const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression"
const sessionID = "ses_timeline_state_regression"
const userMessageID = "msg_user_regression"
const assistantMessageID = "msg_assistant_regression"
const editPartID = "prt_0001_edit"
export const textPartID = "prt_9999_text"
const title = "Timeline collapse state regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type EventPayload = {
directory: string
payload: Record<string, unknown>
}
const userMessage = {
info: {
id: userMessageID,
sessionID,
role: "user",
time: { created: 1700000000000 },
summary: { diffs: [] },
agent: "build",
model,
},
parts: [
{
id: "prt_user_text",
sessionID,
messageID: userMessageID,
type: "text",
text: "Please edit the file.",
},
],
}
const editPart = {
id: editPartID,
sessionID,
messageID: assistantMessageID,
type: "tool",
callID: "call_edit_regression",
tool: "edit",
state: {
status: "completed",
input: { filePath: "src/regression.ts" },
output: "Edited src/regression.ts",
title: "src/regression.ts",
metadata: {
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: "export const value = 'before'\n",
after: "export const value = 'after'\n",
},
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
},
time: { start: 1700000001000, end: 1700000002000 },
},
}
const streamedTextPart = {
id: textPartID,
sessionID,
messageID: assistantMessageID,
type: "text",
text: "Streaming added a later assistant text part.",
}
const assistantMessage = {
info: {
id: assistantMessageID,
sessionID,
role: "assistant",
time: { created: 1700000001000 },
parentID: userMessageID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
},
parts: [editPart],
}
export async function setupTimelineBenchmark(page: Page, options: { historyTurns: number; eventBatch: number }) {
const events: EventPayload[] = []
let eventBatch = options.eventBatch
await mockOpenCodeServer(page, {
directory,
project: project(),
provider: provider(),
sessions: [session()],
pageMessages: () => ({
items: [
...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(),
userMessage,
assistantMessage,
],
}),
events: () => events.splice(0, eventBatch),
eventRetry: 16,
})
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
})
await page.setViewportSize({ width: 1366, height: 768 })
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expectAppVisible(scroller)
return {
scroller,
text,
transport: {
enqueue(payload: EventPayload | EventPayload[]) {
events.push(...(Array.isArray(payload) ? payload : [payload]))
},
pendingCount() {
return events.length
},
releaseAll() {
eventBatch = events.length
},
},
async scrollToBottom() {
await scroller.evaluate((element) => {
element.scrollTop = element.scrollHeight
})
},
async waitForStableGeometry() {
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await page.waitForFunction((partID) => {
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector(`[data-timeline-part-id="${partID}"]`),
)
if (!root) return false
return new Promise<boolean>((resolve) => {
const height = root.scrollHeight
requestAnimationFrame(() =>
requestAnimationFrame(() =>
resolve(root.scrollHeight === height && root.scrollHeight - root.clientHeight - root.scrollTop <= 1),
),
)
})
}, textPartID)
},
}
}
export function buildInitialStreamEvent(deltaCount: number): EventPayload {
return {
directory,
payload: {
type: "message.part.updated",
properties: {
part: {
...streamedTextPart,
text: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
},
},
},
}
}
export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
return Array.from({ length: deltaCount }, (_, index) => ({
directory,
payload: {
type: "message.part.delta",
properties: {
messageID: assistantMessageID,
partID: textPartID,
field: "text",
delta: streamChunk(index + 1, deltaCount + 1),
},
},
}))
}
function performanceTurn(index: number) {
const suffix = String(index).padStart(4, "0")
const userID = `msg_0000_${suffix}_a_user`
const assistantID = `msg_0000_${suffix}_b_assistant`
const before = historicalSource(index, false)
const after = historicalSource(index, true)
const parts = [
...(index % 5 === 0
? [
{
id: `prt_0000_${suffix}_reasoning`,
sessionID,
messageID: assistantID,
type: "reasoning",
text: `Reviewing the existing implementation. ${"constraint analysis ".repeat(20)}`,
time: { start: 1690000001000 + index * 2_000, end: 1690000001200 + index * 2_000 },
},
]
: []),
{
id: `prt_0000_${suffix}_assistant`,
sessionID,
messageID: assistantID,
type: "text",
text: historicalMarkdown(index),
},
...(index % 8 === 0
? [
{
id: `prt_0000_${suffix}_edit`,
sessionID,
messageID: assistantID,
type: "tool",
callID: `call_0000_${suffix}_edit`,
tool: "edit",
state: {
status: "completed",
input: { filePath: `src/history-${index}.ts` },
output: `Edited src/history-${index}.ts`,
title: `src/history-${index}.ts`,
metadata: {
filediff: { file: `src/history-${index}.ts`, additions: 48, deletions: 48, before, after },
},
time: { start: 1690000001200 + index * 2_000, end: 1690000001400 + index * 2_000 },
},
},
]
: []),
...(index % 12 === 0
? [
{
id: `prt_0000_${suffix}_write`,
sessionID,
messageID: assistantID,
type: "tool",
callID: `call_0000_${suffix}_write`,
tool: "write",
state: {
status: "completed",
input: { filePath: `src/generated-${index}.tsx`, content: after },
output: `Wrote src/generated-${index}.tsx`,
title: `src/generated-${index}.tsx`,
metadata: {
filediff: { file: `src/generated-${index}.tsx`, additions: 32, deletions: 0, before: "", after },
},
time: { start: 1690000001400 + index * 2_000, end: 1690000001500 + index * 2_000 },
},
},
]
: []),
...(index % 16 === 0
? [
{
id: `prt_0000_${suffix}_patch`,
sessionID,
messageID: assistantID,
type: "tool",
callID: `call_0000_${suffix}_patch`,
tool: "apply_patch",
state: {
status: "completed",
input: { patchText: realisticPatch(index) },
output: "Success. Updated src/components/SessionCard.tsx",
title: "src/components/SessionCard.tsx",
metadata: {
files: [
{
filePath: "src/components/SessionCard.tsx",
relativePath: "src/components/SessionCard.tsx",
type: "update",
additions: 8,
deletions: 3,
patch: realisticPatch(index),
before,
after,
},
],
},
time: { start: 1690000001500 + index * 2_000, end: 1690000001700 + index * 2_000 },
},
},
]
: []),
]
return [
{
info: {
id: userID,
sessionID,
role: "user",
time: { created: 1690000000000 + index * 2_000 },
summary: { diffs: [] },
agent: "build",
model,
},
parts: [
{
id: `prt_0000_${suffix}_user`,
sessionID,
messageID: userID,
type: "text",
text: `Historical prompt ${index}`,
},
],
},
{
info: {
id: assistantID,
sessionID,
role: "assistant",
time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 },
parentID: userID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
finish: "stop",
},
parts,
},
]
}
function historicalMarkdown(index: number) {
const code = `import { For, Show, createSignal } from "solid-js"
type SessionRow = { id: string; title: string; active: boolean }
export function SessionList(props: { rows: SessionRow[] }) {
const [selected, setSelected] = createSignal<string>()
return (
<section aria-label="Sessions">
<For each={props.rows}>{(row) => (
<button classList={{ active: row.active }} onClick={() => setSelected(row.id)}>
<Show when={selected() === row.id} fallback={row.title}>{row.title.toUpperCase()}</Show>
</button>
)}</For>
</section>
)
}`
return `## Session renderer review ${index}
The active session keeps **semantic row identity** while reconciling measured content. See [Solid documentation](https://docs.solidjs.com/) and the inline \`measureElement(node)\` call.
| Concern | Current behavior | Verification |
| --- | --- | --- |
| streaming | appends Markdown blocks | painted frames |
| geometry | anchors visible rows | DOM coordinates |
| tools | preserves expanded state | keyed remount probe |
> Long sessions combine Markdown, syntax highlighting, tool output, and asynchronously rendered diffs.
${index % 4 === 0 ? `\`\`\`tsx\n${code}\n\`\`\`\n\n\`\`\`bash\nbun typecheck\nbun test --preload ./happydom.ts ./src/pages/session\ngit diff --check\n\`\`\`` : "- preserve the viewport anchor\n- avoid replacing stable Markdown nodes\n- process provider deltas without blocking input"}`
}
function historicalSource(index: number, updated: boolean) {
const method = updated ? "toLocaleUpperCase(props.locale)" : "toUpperCase()"
const limit = updated ? 24 : 20
return `import { createMemo, For } from "solid-js"
type Message = {
id: string
role: "user" | "assistant"
text: string
tokens: { input: number; output: number }
}
export function MessageSummary(props: { messages: Message[]; locale: string }) {
const visible = createMemo(() => props.messages.filter((message) => message.text.trim()).slice(-${limit}))
const total = createMemo(() => visible().reduce((sum, message) => sum + message.tokens.output, 0))
return (
<article data-session-index="${index}">
<header>{total().toLocaleString(props.locale)} output tokens</header>
<For each={visible()}>{(message) => <p data-role={message.role}>{message.text.${method}}</p>}</For>
</article>
)
}
`
}
function realisticPatch(index: number) {
return `*** Begin Patch
*** Update File: src/components/SessionCard.tsx
@@
-const title = props.session.title.toUpperCase()
-const messages = props.messages.slice(-20)
+const title = props.session.title.toLocaleUpperCase(props.locale)
+const messages = props.messages.filter((message) => message.text.trim()).slice(-24)
+const outputTokens = messages.reduce((sum, message) => sum + message.tokens.output, 0)
@@
- <h2>{title}</h2>
+ <h2 data-session-index="${index}">{title}</h2>
+ <span>{outputTokens.toLocaleString(props.locale)} output tokens</span>
*** End Patch`
}
export function streamChunk(index: number, count: number) {
if (index === 0) return `\n\n## Implementation plan\n\nStreaming **bold analysis`
if (index === count - 1)
return `\n\`\`\`\n\n## Verification\n\n- **Typecheck:** passed\n- **Timeline geometry:** stable\n- **Streaming output:** benchmark-complete <!-- stream-${index} -->`
const section = Math.floor(index / 18) + 1
const fragments = [
` continues across three`,
` or four word`,
` provider deltas and`,
` closes in this fragment**. <!-- stream-${index} -->\n\n`,
`| Concern | State`,
` | Verification |\n|`,
` --- | ---`,
` | --- |\n|`,
` markdown | incremental |`,
` painted frames | <!-- stream-${index} -->\n\n`,
`\`\`\`tsx\nconst row: SessionRow`,
` = rows[index] ??`,
` fallback\nconst title =`,
` row.title.toLocaleUpperCase(locale)\n`,
`const selected = createMemo(()`,
` => row.id ===`,
` activeID()) // stream-${index}\n`,
`// stream-${index}\n\`\`\`\n\n### Iteration ${section}\n\nStreaming **bold analysis`,
]
return fragments[(index - 1) % fragments.length]!
}
function project() {
return {
id: projectID,
worktree: directory,
vcs: "git",
name: "timeline-state-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
}
}
function session() {
return {
id: sessionID,
slug: "timeline-state-regression",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
}
}
function provider() {
return {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}
@@ -0,0 +1,85 @@
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
import {
buildInitialStreamEvent,
buildStreamDeltaEvents,
setupTimelineBenchmark,
textPartID,
} from "./session-timeline-benchmark.fixture"
import { startTimelineProfile } from "./session-timeline-profile"
import {
collectTimelineStreamMetrics,
installTimelineStreamProbe,
startTimelineStreamProbe,
} from "./session-timeline-stream-probe"
benchmark.describe("performance: session timeline streaming", () => {
benchmark("streams assistant text without remounting or oscillating", async ({ page, report }) => {
benchmark.setTimeout(480_000)
const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30)
const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160)
const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320)
const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1)
const minimal = process.env.TIMELINE_MINIMAL === "1"
const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1"
const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0"
const fixture = await setupTimelineBenchmark(page, {
historyTurns,
eventBatch,
})
fixture.transport.enqueue(buildInitialStreamEvent(deltaCount))
const contentStart = performance.now()
await expect(fixture.text).toBeVisible()
await expect(fixture.text).toContainText("Implementation plan")
const initialContentObservedMs = performance.now() - contentStart
await fixture.scrollToBottom()
await fixture.waitForStableGeometry()
const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU })
await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal })
const deltas = buildStreamDeltaEvents(deltaCount)
await startTimelineStreamProbe(page)
fixture.transport.enqueue(deltas)
await page.waitForFunction(
(finalIndex) =>
(
window as Window & {
__timelineStreamBenchmark?: { applied: { index: number }[] }
}
).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex),
deltaCount,
{ timeout: 420_000 },
)
await expect(fixture.text).toContainText("benchmark-complete")
await expect(fixture.text).toContainText("Streaming")
await fixture.waitForStableGeometry()
const metrics = await collectTimelineStreamMetrics(page, {
textPartID,
finalIndex: deltaCount,
navigations: benchmarkDiagnostics(page).navigations,
})
const delivered = deltas.length - fixture.transport.pendingCount()
await profile.stop()
report(
{
endToEndInitialContentObservedMs: initialContentObservedMs,
...metrics,
deliveredDeltas: delivered,
pendingDeltas: fixture.transport.pendingCount(),
},
{
cpuThrottle,
profileCPU,
profileVisual,
minimal,
queuedDeltas: deltas.length,
historyTurns,
eventBatch,
},
)
await profile.reset()
})
})
@@ -0,0 +1,40 @@
import type { CDPSession, Page } from "@playwright/test"
export async function startTimelineProfile(page: Page, options: { cpuThrottle: number; profileCPU: boolean }) {
const cdp = await page.context().newCDPSession(page)
if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: options.cpuThrottle })
if (options.profileCPU) {
await cdp.send("Profiler.enable")
await cdp.send("Profiler.setSamplingInterval", { interval: 100 })
await cdp.send("Profiler.start")
}
return {
async stop() {
if (!options.profileCPU) return
const result = await cdp.send("Profiler.stop")
const self = new Map<number, number>()
result.profile.samples?.forEach((id, index) => {
const duration = (result.profile.timeDeltas?.[index] ?? 0) / 1_000
self.set(id, (self.get(id) ?? 0) + duration)
})
console.log(
"timeline cpu profile",
JSON.stringify(
result.profile.nodes
.map((node) => ({
function: node.callFrame.functionName || "(anonymous)",
url: node.callFrame.url,
line: node.callFrame.lineNumber + 1,
selfMs: self.get(node.id) ?? 0,
}))
.filter((node) => node.selfMs > 1)
.sort((a, b) => b.selfMs - a.selfMs)
.slice(0, 40),
),
)
},
async reset() {
if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 })
},
}
}
@@ -0,0 +1,547 @@
import type { Page } from "@playwright/test"
const STREAM_MARKER_PATTERN = "stream-(\\d+)"
const STREAM_FRAGMENT_COUNT = 18
type TimelineProbeState = {
started: number
ended: number
profileVisual: boolean
minimal: boolean
frames: number[]
frameAt: number[]
applied: { at: number; index: number }[]
geometry: {
scrollTop: number
scrollHeight: number
clientHeight: number
distance: number
virtualHeight: number
headerHeight: number
}[]
blanks: number
longTasks: number[]
layoutShifts: number[]
visibleMounts: number
visibleUnmounts: number
visibleRows: Set<Element>
visibleSubtreeMounts: string[]
visibleSubtreeUnmounts: string[]
visibleSubtreeReplacements: number
visibleSubtreeDropouts: string[]
visibleSubtrees: Map<string, Element>
subtreeKeys: WeakMap<Element, string>
maxOverlap: number
maxGap: number
maxPartTopMovement: number
previousPartTop: number
slowFrames: {
duration: number
index: number
phase: "stream" | "boundary" | "complete" | "unknown"
tokenSpans: number
blocks: number
codeBlocks: number
height: number
distance: number
}[]
scroll: {
calls: number
callNoops: number
sameFrameCalls: number
assignments: number
assignmentNoops: number
lastCallFrame: number
frame: number
}
row: HTMLElement
markdown: HTMLElement
running: boolean
previous: number
cleanup: () => void
start: () => void
}
export async function installTimelineStreamProbe(
page: Page,
options: { textPartID: string; finalIndex: number; profileVisual: boolean; minimal: boolean },
) {
await page.evaluate(
({ textPartID, finalIndex, profileVisual, minimal, markerPattern, fragmentCount }) => {
const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
const row = part?.closest<HTMLElement>("[data-timeline-row]")
const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
const root = part?.closest<HTMLElement>(".scroll-view__viewport")
if (!part || !row || !markdown || !root) throw new Error("missing streaming benchmark nodes")
const viewport = root.getBoundingClientRect()
const state: TimelineProbeState = {
started: 0,
ended: Infinity,
profileVisual,
minimal,
frames: [],
frameAt: [],
applied: [],
geometry: [],
blanks: 0,
longTasks: [],
layoutShifts: [],
visibleMounts: 0,
visibleUnmounts: 0,
visibleRows: new Set(
[...root.querySelectorAll("[data-timeline-key]")].filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > viewport.top && rect.top < viewport.bottom
}),
),
visibleSubtreeMounts: [],
visibleSubtreeUnmounts: [],
visibleSubtreeReplacements: 0,
visibleSubtreeDropouts: [],
visibleSubtrees: new Map<string, Element>(),
subtreeKeys: new WeakMap<Element, string>(),
maxOverlap: 0,
maxGap: 0,
maxPartTopMovement: 0,
previousPartTop: part.getBoundingClientRect().top,
slowFrames: [],
scroll: {
calls: 0,
callNoops: 0,
sameFrameCalls: 0,
assignments: 0,
assignmentNoops: 0,
lastCallFrame: -1,
frame: 0,
},
row,
markdown,
running: false,
previous: 0,
cleanup: () => {},
start: () => {},
}
;(window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark = state
const scrollTo = Element.prototype.scrollTo
const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
if (profileVisual) {
Element.prototype.scrollTo = function (...args) {
state.scroll.calls += 1
const top = typeof args[0] === "object" ? args[0]?.top : args[1]
if (typeof top === "number") {
const target = Math.min(top, this.scrollHeight - this.clientHeight)
if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
}
if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
state.scroll.lastCallFrame = state.scroll.frame
return scrollTo.apply(this, args)
}
Object.defineProperty(Element.prototype, "scrollTop", {
configurable: true,
get: scrollTop.get,
set(value) {
state.scroll.assignments += 1
if (Math.abs(this.scrollTop - value) < 1) state.scroll.assignmentNoops += 1
scrollTop.set!.call(this, value)
},
})
}
const recordLongTasks = (entries: PerformanceEntry[]) => {
if (!state.running) return
state.longTasks.push(
...entries
.filter((entry) => entry.startTime >= state.started && entry.startTime <= state.ended)
.map((entry) => entry.duration),
)
}
const longTaskObserver = new PerformanceObserver((list) => recordLongTasks(list.getEntries()))
longTaskObserver.observe({ type: "longtask" })
const recordLayoutShifts = (entries: PerformanceEntry[]) => {
if (!state.running) return
state.layoutShifts.push(
...entries
.map((entry) => {
const shift = entry as LayoutShiftEntry
if (shift.startTime < state.started || shift.hadRecentInput) return
return shift.value
})
.filter((value): value is number => value !== undefined),
)
}
const layoutShiftObserver = profileVisual
? new PerformanceObserver((list) => recordLayoutShifts(list.getEntries()))
: undefined
layoutShiftObserver?.observe({ type: "layout-shift", buffered: true })
const visible = (element: Element) => {
const rect = element.getBoundingClientRect()
const viewport = root.getBoundingClientRect()
const style = getComputedStyle(element)
return (
element.isConnected &&
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > viewport.top &&
rect.top < viewport.bottom &&
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) > 0
)
}
const critical = [
"[data-timeline-part-id]",
'[data-component="edit-content"]',
'[data-component="apply-patch-file-diff"]',
'[data-component="file"]',
'[data-component="markdown-code"]',
"[data-markdown-block]",
].join(",")
const describe = (element: Element) => {
const cached = state.subtreeKeys.get(element)
if (!element.isConnected && cached) return cached
const part = element.closest<HTMLElement>("[data-timeline-part-id]")?.dataset.timelinePartId ?? "unknown"
const block = element
.closest<HTMLElement>("[data-markdown-key]")
?.dataset.markdownKey?.replace(/:(?:code|full|live)$/, "")
const component =
element.getAttribute("data-component") ?? element.getAttribute("data-markdown-block") ?? element.tagName
const key = `${part}:${block ?? "root"}:${component}`
state.subtreeKeys.set(element, key)
return key
}
const recordMutations = (records: MutationRecord[]) => {
if (!state.running) return
records.forEach((record) => {
record.addedNodes.forEach((node) => {
if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && visible(node)) {
state.visibleMounts += 1
state.visibleRows.add(node)
}
if (!(node instanceof Element)) return
const added = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
added.forEach((element) => {
if (visible(element)) state.visibleSubtreeMounts.push(describe(element))
})
})
record.removedNodes.forEach((node) => {
if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && state.visibleRows.delete(node))
state.visibleUnmounts += 1
if (!(node instanceof Element)) return
const removed = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
removed.forEach((element) => {
const key = describe(element)
if (state.visibleSubtrees.get(key) === element) state.visibleSubtreeUnmounts.push(key)
})
})
})
}
const mutationObserver = profileVisual ? new MutationObserver(recordMutations) : undefined
mutationObserver?.observe(root, { childList: true, subtree: true })
const currentPart = () => root.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
const observeProgress = (at: number) => {
if (!state.running) return
const content = currentPart()?.textContent ?? ""
const index = content.includes("benchmark-complete")
? finalIndex
: Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
if (index >= 0 && index !== state.applied.at(-1)?.index) state.applied.push({ at, index })
}
const progressObserver = new MutationObserver(() => observeProgress(performance.now()))
progressObserver.observe(root, { characterData: true, childList: true, subtree: true })
state.cleanup = () => {
recordLongTasks(longTaskObserver.takeRecords())
recordLayoutShifts(layoutShiftObserver?.takeRecords() ?? [])
recordMutations(mutationObserver?.takeRecords() ?? [])
if (progressObserver.takeRecords().length) observeProgress(performance.now())
longTaskObserver.disconnect()
layoutShiftObserver?.disconnect()
mutationObserver?.disconnect()
progressObserver.disconnect()
if (!profileVisual) return
Element.prototype.scrollTo = scrollTo
Object.defineProperty(Element.prototype, "scrollTop", scrollTop)
}
const sample = (now: number) => {
if (!state.running) return
state.frameAt.push(now)
observeProgress(now)
if (minimal) {
state.frames.push(now - state.previous)
state.previous = now
requestAnimationFrame(sample)
return
}
setTimeout(() => {
if (!state.running) return
state.scroll.frame += 1
const duration = now - state.previous
state.frames.push(duration)
state.previous = now
const virtualRoot = root.querySelector<HTMLElement>("[data-timeline-virtual-content]")
const header = root.querySelector<HTMLElement>("[data-session-title]")
state.geometry.push({
scrollTop: root.scrollTop,
scrollHeight: root.scrollHeight,
clientHeight: root.clientHeight,
distance: root.scrollHeight - root.clientHeight - root.scrollTop,
virtualHeight: virtualRoot?.getBoundingClientRect().height ?? 0,
headerHeight: header?.getBoundingClientRect().height ?? 0,
})
const viewport = root.getBoundingClientRect()
if (profileVisual) {
const visibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
.filter((item) => item.rect.bottom > viewport.top && item.rect.top < viewport.bottom)
.sort((a, b) => a.rect.top - b.rect.top)
state.visibleRows = new Set(visibleRows.map((item) => item.element))
const rows = visibleRows.map((item) => item.rect)
rows.slice(1).forEach((rect, index) => {
const previous = rows[index]!
state.maxOverlap = Math.max(state.maxOverlap, previous.bottom - rect.top)
state.maxGap = Math.max(state.maxGap, rect.top - previous.bottom)
})
const partTop = part.getBoundingClientRect().top
state.maxPartTopMovement = Math.max(state.maxPartTopMovement, Math.abs(partTop - state.previousPartTop))
state.previousPartTop = partTop
}
const visibleRow = [...root.querySelectorAll<HTMLElement>("[data-timeline-row]")].some((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > viewport.top && rect.top < viewport.bottom
})
if (!visibleRow) state.blanks += 1
if (profileVisual) {
const subtrees = new Map<string, { element: Element; rendered: boolean }>()
const visibleSubtrees = new Map<string, Element>()
root.querySelectorAll(critical).forEach((element) => {
const key = describe(element)
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
const rendered =
element.isConnected &&
rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) > 0
subtrees.set(key, { element, rendered })
if (rendered && rect.bottom > viewport.top && rect.top < viewport.bottom) {
const previous = state.visibleSubtrees.get(key)
if (previous && previous !== element && key.startsWith(`${textPartID}:`))
state.visibleSubtreeReplacements += 1
visibleSubtrees.set(key, element)
}
})
state.visibleSubtrees.forEach((element, key) => {
const current = subtrees.get(key)
if (key.startsWith(`${textPartID}:`) && !current?.rendered) {
const markdown = part.querySelector<HTMLElement>('[data-component="markdown"]')
state.visibleSubtreeDropouts.push(
`${key}:projection=${markdown?.dataset.markdownProjectionLength}/${markdown?.dataset.markdownProjectionBlocks}:result=${markdown?.dataset.markdownResultLength}/${markdown?.dataset.markdownResultBlocks}:applied=${markdown?.dataset.markdownAppliedBlocks}:dom=${markdown?.children.length}`,
)
}
if (element.matches('[data-component="file"]')) {
const hadLines = element.hasAttribute("data-profiler-had-lines")
const hasLines = element.shadowRoot?.querySelector("[data-line]") != null
if (hasLines) element.setAttribute("data-profiler-had-lines", "")
if (hadLines && !hasLines) state.visibleSubtreeDropouts.push(`${key}:shadow-lines`)
}
})
state.visibleSubtrees = visibleSubtrees
}
if (profileVisual && duration > 33.34) {
const livePart = currentPart()
const content = livePart?.textContent ?? ""
const complete = content.includes("benchmark-complete")
const index = complete
? finalIndex
: Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
state.slowFrames.push({
duration,
index,
phase: complete
? "complete"
: index >= 0 && index % fragmentCount === 0
? "boundary"
: index >= 0
? "stream"
: "unknown",
tokenSpans: livePart?.querySelectorAll(".shiki span").length ?? 0,
blocks: livePart?.querySelectorAll("[data-markdown-block]").length ?? 0,
codeBlocks: livePart?.querySelectorAll('[data-component="markdown-code"]').length ?? 0,
height: livePart?.getBoundingClientRect().height ?? 0,
distance: root.scrollHeight - root.clientHeight - root.scrollTop,
})
}
requestAnimationFrame(sample)
}, 0)
}
state.start = () => {
state.started = performance.now()
state.previous = state.started
state.running = true
requestAnimationFrame(sample)
}
},
{ ...options, markerPattern: STREAM_MARKER_PATTERN, fragmentCount: STREAM_FRAGMENT_COUNT },
)
}
export function startTimelineStreamProbe(page: Page) {
return page.evaluate(() => {
const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
if (!state) throw new Error("missing streaming benchmark state")
state.start()
})
}
type LayoutShiftEntry = PerformanceEntry & { value: number; hadRecentInput?: boolean }
export function layoutShiftValue(
entry: Pick<LayoutShiftEntry, "startTime" | "value" | "hadRecentInput">,
start: number,
) {
if (entry.startTime < start || entry.hadRecentInput) return
return entry.value
}
export function removeVisibleRow<T>(visible: Set<T>, row: T) {
return visible.delete(row)
}
export function streamProgress(content: string) {
const index = Number(content.match(new RegExp(STREAM_MARKER_PATTERN, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
return {
index,
phase: content.includes("benchmark-complete")
? ("complete" as const)
: index >= 0 && index % STREAM_FRAGMENT_COUNT === 0
? ("boundary" as const)
: index >= 0
? ("stream" as const)
: ("unknown" as const),
}
}
export async function collectTimelineStreamMetrics(
page: Page,
options: { textPartID: string; finalIndex: number; navigations: string[] },
) {
return page.evaluate(({ textPartID, finalIndex, navigations }) => {
const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
if (!state) throw new Error(`missing streaming benchmark state after navigation: ${JSON.stringify(navigations)}`)
state.ended = performance.now()
state.cleanup()
state.running = false
const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
const row = part?.closest<HTMLElement>("[data-timeline-row]")
const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
const sorted = state.frames.slice().sort((a, b) => a - b)
const duration = state.frames.reduce((sum, value) => sum + value, 0)
const longestSlowStreak = state.frames.reduce(
(result, value) => {
const current = value > 33.34 ? result.current + 1 : 0
return { current, longest: Math.max(result.longest, current) }
},
{ current: 0, longest: 0 },
).longest
const busyStart = state.applied.at(0)?.at
const completion = state.applied.find((value) => value.index === finalIndex)
const busyEnd = completion?.at
const busyFrames =
busyStart === undefined || busyEnd === undefined
? []
: state.frames.filter((_, index) => state.frameAt[index]! >= busyStart && state.frameAt[index]! <= busyEnd)
const busySorted = busyFrames.slice().sort((a, b) => a - b)
const busyDuration = busyFrames.reduce((sum, value) => sum + value, 0)
const completionObservedMs = (completion?.at ?? NaN) - state.started
const visual = state.profileVisual
? {
layoutShiftValueSum: state.layoutShifts.reduce((sum, value) => sum + value, 0),
maxLayoutShiftValue: Math.max(0, ...state.layoutShifts),
visibleMounts: state.visibleMounts,
visibleUnmounts: state.visibleUnmounts,
visibleSubtreeMounts: state.visibleSubtreeMounts,
visibleSubtreeUnmounts: [...new Set(state.visibleSubtreeUnmounts)],
visibleSubtreeReplacements: state.visibleSubtreeReplacements,
visibleSubtreeDropouts: [...new Set(state.visibleSubtreeDropouts)],
maxOverlapPx: state.maxOverlap,
maxGapPx: state.maxGap,
maxPartTopMovementPx: state.maxPartTopMovement,
slowestRafGaps: state.slowFrames
.sort((a, b) => b.duration - a.duration)
.slice(0, 20)
.map((frame) => ({
durationMs: frame.duration,
index: frame.index,
phase: frame.phase,
tokenSpans: frame.tokenSpans,
blocks: frame.blocks,
codeBlocks: frame.codeBlocks,
heightPx: frame.height,
distancePx: frame.distance,
})),
slowRafGapPhases: Object.fromEntries(
["stream", "boundary", "complete", "unknown"].map((phase) => {
const frames = state.slowFrames.filter((frame) => frame.phase === phase)
return [
phase,
{
count: frames.length,
totalMs: frames.reduce((sum, frame) => sum + frame.duration, 0),
maxMs: Math.max(0, ...frames.map((frame) => frame.duration)),
},
]
}),
),
scroll: state.scroll,
}
: null
const geometry = state.minimal
? null
: {
maxDistancePx: Math.max(0, ...state.geometry.map((sample) => sample.distance)),
finalDistancePx: state.geometry.at(-1)?.distance ?? 0,
final: state.geometry.at(-1),
distanceTransitionsPx: state.geometry
.map((sample) => Math.round(sample.distance))
.filter((value, index, values) => index === 0 || value !== values[index - 1]),
bottomDriftTransitions: state.geometry.slice(1).filter((value, index) => {
const previous = state.geometry[index]?.distance ?? 0
return previous <= 1 && value.distance > 1
}).length,
blankSamples: state.blanks,
}
return {
capabilities: { visual: state.profileVisual, geometry: !state.minimal },
completionObservedMs,
deltasPerSecond: Number.isFinite(completionObservedMs) ? finalIndex / (completionObservedMs / 1_000) : null,
rafGapSamples: state.frames.length,
rafCallbackRate: duration ? (state.frames.length * 1000) / duration : 0,
observedProgressWindowRafCallbackRate: busyDuration ? (busyFrames.length * 1000) / busyDuration : null,
observedProgressWindowRafGapP95Ms: busySorted[Math.floor(busySorted.length * 0.95)] ?? null,
observedProgressWindowRafGaps: busyFrames.length,
maxObservedProgressIndex: Math.max(-1, ...state.applied.map((value) => value.index)),
observedProgressTransitions: state.applied.length,
rafGapP50Ms: sorted[Math.floor(sorted.length * 0.5)] ?? 0,
rafGapP95Ms: sorted[Math.floor(sorted.length * 0.95)] ?? 0,
rafGapP99Ms: sorted[Math.floor(sorted.length * 0.99)] ?? 0,
maxRafGapMs: sorted.at(-1) ?? 0,
rafGapsOver33Ms: state.frames.filter((value) => value > 33.34).length,
rafGapsOver50Ms: state.frames.filter((value) => value > 50).length,
missedFrameBudgetEquivalents: state.frames.reduce(
(sum, value) => sum + Math.max(0, Math.round(value / 16.67) - 1),
0,
),
longestRafGapOver33MsStreak: longestSlowStreak,
longTaskCount: state.longTasks.length,
longTaskTimeMs: state.longTasks.reduce((sum, value) => sum + value, 0),
visual,
geometry,
rowReplaced: row !== state.row,
markdownReplaced: markdown !== state.markdown,
domTextCharacters: part?.textContent?.length ?? 0,
}
}, options)
}
@@ -0,0 +1,335 @@
const words = [
"alpha",
"bravo",
"charlie",
"delta",
"echo",
"foxtrot",
"golf",
"hotel",
"india",
"juliet",
"kilo",
"lima",
"metro",
"nova",
"orbit",
"pixel",
"quartz",
"river",
"signal",
"vector",
]
const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
type Message = { info: MessageInfo; parts: MessagePart[] }
function lorem(seed: number, length: number) {
let out = ""
let i = seed
while (out.length < length) {
const word = words[i % words.length]
out += (out ? " " : "") + word
if (i % 17 === 0) out += ".\n\n"
i += 7
}
return out.slice(0, length)
}
function id(prefix: string, value: number) {
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
}
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
const messageID = id("msg_user", index)
return {
info: {
id: messageID,
sessionID,
role: "user",
time: { created: 1700000000000 + index * 10_000 },
summary: { diffs },
agent: "build",
model,
},
parts: [
{
id: id("prt_user_text", index),
sessionID,
messageID,
type: "text",
text: lorem(index, textLength),
},
],
}
}
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
const messageID = id("msg_assistant", index)
return {
info: {
id: messageID,
sessionID,
role: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
parentID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
finish: "stop",
},
parts: parts.map((part) => ({
...part,
sessionID,
messageID,
})),
}
}
function textPart(index: number, partIndex: number, length: number): MessagePart {
const prose = lorem(index * 13 + partIndex, length)
const text =
index % 12 === 0
? `${prose}\n\n\`\`\`ts\n${code(index, 80)}\n\`\`\``
: index % 5 === 0
? `${prose}\n\n\`\`\`ts\nexport const value = "${lorem(index, 220)}"\n\`\`\``
: index % 7 === 0
? `${prose}\n\nThe wrapped inline value is \`${lorem(index, 180)}\`.`
: prose
return { id: id(`prt_text_${partIndex}`, index), type: "text", text }
}
function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
return {
id: id(`prt_reasoning_${partIndex}`, index),
type: "reasoning",
text: lorem(index * 19 + partIndex, length),
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
}
}
function toolPart(
index: number,
partIndex: number,
tool: string,
input: Record<string, unknown>,
outputLength = 160,
): MessagePart {
const metadata =
tool === "apply_patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write"
? {
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
diff: patch(index, outputLength),
preview: patch(index + 1, 420),
}
: tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] }
: {}
return {
id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool",
callID: id("call", index * 10 + partIndex),
tool,
state: {
status: "completed",
input,
output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
metadata,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
},
}
}
function patchFile(seed: number, type: "add" | "update" | "delete") {
return {
filePath: `src/generated/patch-${seed}.ts`,
relativePath: `src/generated/patch-${seed}.ts`,
type,
additions: (seed % 7) + 1,
deletions: type === "add" ? 0 : seed % 4,
patch: patch(seed, 520),
before: type === "add" ? undefined : code(seed, 18),
after: type === "delete" ? undefined : code(seed + 1, 24),
}
}
function fileDiff(file: string, seed: number) {
const lines = seed % 12 === 0 ? 300 : seed % 8 === 0 ? 2 : 38
const before = code(seed, lines, seed % 10 === 0 ? 280 : 32)
const after =
lines === 2
? before.replace("value1", "updatedValue1")
: lines === 300
? code(seed + 1, lines, seed % 10 === 0 ? 280 : 32)
: before.replace("value4", "updatedValue4").replace("value20", "updatedValue20")
return {
file,
additions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
deletions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
before,
after,
}
}
function patch(seed: number, length: number) {
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
}
function code(seed: number, lines: number, width = 32) {
return Array.from(
{ length: lines },
(_, index) => `export const value${index} = "${lorem(seed + index, width)}"`,
).join("\n")
}
function turn(index: number): Message[] {
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
const parts = [
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
...(index % 3 === 0
? [
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
]
: []),
textPart(index, 2, 160 + (index % 6) * 90),
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
...(index % 6 === 0
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []),
...(index % 7 === 0
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
...(index % 13 === 0
? [
toolPart(
index,
11,
"question",
{ questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
120,
),
]
: []),
...(index % 17 === 0
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
: []),
]
return [user, assistantMessage(targetID, index, user.info.id, parts)]
}
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
userMessage(sourceID, index + 1000, 120),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
]).flat()
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim()
if (part.type === "reasoning") return !!part.text.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
}
function orderedParts(message: Message) {
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}
export const fixture = {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "smoke-project",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [
{
id: sourceID,
slug: "source",
projectID,
directory,
title: "Uncommitted changes inquiry",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
{
id: targetID,
slug: "target",
projectID,
directory,
title: "Example Game: sample jump movement & sample physics analysis",
version: "dev",
time: { created: 1700000001000, updated: 1700000001000 },
},
],
sourceID,
targetID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
expected: {
sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis",
sourceMessageIDs: sourceMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetMessageIDs: targetMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) =>
orderedParts(message)
.filter(renderable)
.map((part) => part.id),
),
},
}
export function pageMessages(sessionID: string, limit: number, before?: string) {
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
const end = before
? Math.max(
0,
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,
}
}
@@ -0,0 +1,67 @@
import type { Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { fixture } from "./session-timeline-stress.fixture"
export async function installTimelineSettings(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
})
}
export function mockStressTimeline(page: Page) {
return mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
})
}
export async function installStressSessionTabs(page: Page) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ directory, sourceID, targetID, dirBase64, server }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
server,
dirBase64,
sessionId,
})),
),
)
},
{
directory: fixture.directory,
sourceID: fixture.sourceID,
targetID: fixture.targetID,
dirBase64: base64Encode(fixture.directory),
server,
},
)
}
export function stressSessionHref(sessionID: string) {
return `/${base64Encode(fixture.directory)}/session/${sessionID}`
}
@@ -0,0 +1,15 @@
import { expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import path from "node:path"
import os from "node:os"
import { prepareChromeTrace } from "../chrome-trace"
test("creates the configured trace directory", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-trace-"))
try {
const file = await prepareChromeTrace(path.join(root, "nested", "traces"), "session/tab", false, "test")
expect(file).toEndWith("-session-tab-458ed9e3-test.json")
} finally {
await rm(root, { recursive: true, force: true })
}
})
@@ -0,0 +1,42 @@
import { expect, test } from "bun:test"
import { compressCachedRepaintTrace, layoutShiftSample } from "../timeline/session-tab-repaint-probe"
test("compresses repeated repaint states without losing frame samples", () => {
const state = {
root: 1,
scrollTop: 10,
scrollHeight: 20,
bottomErrorPx: 0,
last: true,
rows: [{ key: "row", node: 2, top: 0, bottom: 10 }],
mounted: 1,
center: "content",
}
const trace = {
timeOriginEpochMs: 1_000,
startedAtPerformanceMs: 100,
samples: [
{ observedAtMs: 16, ...state, destination: ["target"], source: [] },
{ observedAtMs: 32, ...state, destination: ["target"], source: [] },
{ observedAtMs: 48, ...state, scrollTop: 11, destination: ["target"], source: [] },
],
mutations: [{ observedAtMs: 20, changed: [{ type: "add", node: 2 }] }],
shifts: [{ occurredAtMs: 24, value: 0.1 }],
windowMs: 1_000,
running: false,
stop() {},
}
const compressed = compressCachedRepaintTrace(trace)
const samples = compressed.samples.flatMap((group) =>
group.observedAtMs.map((observedAtMs) => ({ observedAtMs, ...group.state })),
)
expect(samples).toEqual(trace.samples)
expect(compressed.mutations).toEqual(trace.mutations)
expect(compressed.shifts).toEqual(trace.shifts)
})
test("records layout shifts at occurrence time within the probe window", () => {
expect(layoutShiftSample({ startTime: 99, value: 0.1 }, 100)).toBeUndefined()
expect(layoutShiftSample({ startTime: 124, value: 0.2 }, 100)).toEqual({ occurredAtMs: 24, value: 0.2 })
})
@@ -0,0 +1,54 @@
import { expect, test } from "bun:test"
import { classifySessionSwitch } from "../timeline/session-tab-switch-metrics"
test("counts source and blank samples before the destination is observed", () => {
const result = classifySessionSwitch([
{ observedAtMs: 16, destination: [], source: ["source"], hasVisibleRows: true, last: false },
{ observedAtMs: 32, destination: [], source: [], hasVisibleRows: false, last: false },
{ observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
{ observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
{ observedAtMs: 80, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
])
expect(result.blankSamples).toBe(1)
expect(result.sourceSamples).toBe(1)
expect(result.unknownSamples).toBe(0)
expect(result.firstDestinationObservedMs).toBe(48)
expect(result.stableObservedMs).toBe(80)
})
test("does not classify mixed source and destination content as correct", () => {
const result = classifySessionSwitch([
{
observedAtMs: 16,
destination: ["destination"],
source: ["source"],
hasVisibleRows: true,
last: true,
bottomErrorPx: 0,
},
{ observedAtMs: 32, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
{ observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
{ observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
])
expect(result.firstCorrectObservedMs).toBe(32)
expect(result.stableObservedMs).toBe(64)
})
test("reports missing correctness without throwing", () => {
const result = classifySessionSwitch([
{
observedAtMs: 16,
destination: ["destination"],
source: ["source"],
hasVisibleRows: true,
last: true,
bottomErrorPx: 0,
},
])
expect(result.firstDestinationObservedMs).toBe(16)
expect(result.firstCorrectObservedMs).toBeNull()
expect(result.stableObservedMs).toBeNull()
})
@@ -0,0 +1,14 @@
import { expect, test } from "bun:test"
import { streamChunk } from "../timeline/session-timeline-benchmark.fixture"
import { streamProgress } from "../timeline/session-timeline-stream-probe"
test("classifies emitted stream markers using the fixture cycle", () => {
expect(streamProgress("before stream-17 after stream-18")).toEqual({ index: 18, phase: "boundary" })
expect(streamProgress("before stream-18 after stream-19")).toEqual({ index: 19, phase: "stream" })
expect(streamProgress("benchmark-complete stream-36")).toEqual({ index: 36, phase: "complete" })
expect(streamProgress("no marker")).toEqual({ index: -1, phase: "unknown" })
})
test("emits progress markers at fixture boundaries", () => {
expect(streamProgress(streamChunk(18, 160))).toEqual({ index: 18, phase: "boundary" })
})
@@ -0,0 +1,16 @@
import { expect, test } from "bun:test"
import { layoutShiftValue, removeVisibleRow } from "../timeline/session-timeline-stream-probe"
test("excludes layout shifts before the probe window and recent input", () => {
expect(layoutShiftValue({ startTime: 9, value: 0.1 }, 10)).toBeUndefined()
expect(layoutShiftValue({ startTime: 10, value: 0.2, hadRecentInput: true }, 10)).toBeUndefined()
expect(layoutShiftValue({ startTime: 11, value: 0.3 }, 10)).toBe(0.3)
})
test("classifies removed rows from their last painted visibility", () => {
const row = {}
const visible = new Set([row])
expect(removeVisibleRow(visible, row)).toBe(true)
expect(removeVisibleRow(visible, row)).toBe(false)
})
@@ -147,7 +147,8 @@ test.describe("regression: session timeline local row state", () => {
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
await expectAppVisible(wrapper)
await expectAppVisible(wrapper.locator('[data-component="file"][data-mode="diff"]').first())
const file = wrapper.locator('[data-component="file"][data-mode="diff"]').first()
await expectAppVisible(file)
await markDiffProbe(page)
events.push({
@@ -159,7 +160,15 @@ test.describe("regression: session timeline local row state", () => {
})
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
const siblingProbe = await readDiffProbe(page)
expect(siblingProbe).toEqual({
fileMarker: "before",
frameMarker: "before",
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
rowMarker: "before",
shadowRoots: 0,
toolMarker: "before",
})
await markDiffProbe(page)
events.push({
@@ -173,7 +182,73 @@ test.describe("regression: session timeline local row state", () => {
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
{ timeout: 10_000 },
)
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
expect(await readDiffProbe(page)).toEqual({
fileMarker: "before",
frameMarker: "before",
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
rowMarker: "before",
shadowRoots: 0,
toolMarker: "before",
})
})
test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => {
const events: EventPayload[] = []
const lines = Array.from({ length: 1_000 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = [100, 300, 500, 700, 900].reduce(
(result, index) =>
result.replace(`export const value${index} = ${index}`, `export const value${index} = compute(${index})`),
lines,
)
const part = {
...editPart,
state: {
...editPart.state,
metadata: {
...editPart.state.metadata,
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: lines,
after,
},
},
},
}
await mockServer(page, events, [userMessage, { ...assistantMessage, parts: [part] }])
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]').first()
const diff = wrapper.locator('[data-component="edit-content"]').first()
await expectAppVisible(diff)
await expect.poll(() => wrapper.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(500)
const samples = await wrapper.evaluate(async (element) => {
const root = element.closest<HTMLElement>(".scroll-view__viewport")!
element.scrollIntoView({ block: "start" })
const result = []
for (const offset of [0, 120, 240, 360, 480]) {
root.scrollBy(0, offset - (result.at(-1)?.offset ?? 0))
await new Promise(requestAnimationFrame)
const trigger = element.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')!
const diff = element.querySelector<HTMLElement>('[data-component="edit-content"]')!
result.push({
offset,
trigger: trigger.getBoundingClientRect().y,
diff: diff.getBoundingClientRect().y,
bottom: element.getBoundingClientRect().bottom,
})
}
return result
})
expect(samples[0]!.trigger).toBeLessThan(samples[0]!.diff)
expect(samples.every((sample) => Math.abs(sample.trigger - samples[0]!.trigger) <= 1)).toBe(true)
expect(samples.every((sample) => sample.trigger < sample.bottom)).toBe(true)
})
})
@@ -247,10 +322,16 @@ async function markDiffProbe(page: Page) {
.evaluate((element) => {
const tool = element as HTMLElement
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
const row = tool.closest<HTMLElement>("[data-timeline-key]")
const frame = tool.closest<HTMLElement>("[data-timeline-row]")
if (!file) throw new Error("missing edit diff file")
if (!row) throw new Error("missing virtual timeline row")
if (!frame) throw new Error("missing timeline row frame")
tool.dataset.timelineProbe = "before"
file.dataset.timelineProbe = "before"
row.dataset.timelineProbe = "before"
frame.dataset.timelineProbe = "before"
window.__timelineDiffProbe.reset()
})
}
@@ -262,10 +343,15 @@ async function readDiffProbe(page: Page) {
.evaluate((element) => {
const tool = element as HTMLElement
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
const row = tool.closest<HTMLElement>("[data-timeline-key]")
const frame = tool.closest<HTMLElement>("[data-timeline-row]")
return {
fileMarker: file?.dataset.timelineProbe,
shadowRoots: window.__timelineDiffProbe.shadowRoots(),
toolMarker: tool.dataset.timelineProbe,
rowMarker: row?.dataset.timelineProbe,
rowKey: row?.dataset.timelineKey,
frameMarker: frame?.dataset.timelineProbe,
}
})
}
@@ -300,14 +386,15 @@ function readExpanded(element: Element) {
return !!content && content.getBoundingClientRect().height > 0
}
async function mockServer(page: Page, events: EventPayload[]) {
async function mockServer(page: Page, events: EventPayload[], messages = [userMessage, assistantMessage]) {
await mockOpenCodeServer(page, {
directory,
project: project(),
provider: provider(),
sessions: [session()],
pageMessages: () => ({ items: [userMessage, assistantMessage] }),
events: () => events.splice(0),
pageMessages: () => ({ items: messages }),
events: () => events.splice(0, 1),
eventRetry: 16,
})
}
@@ -32,8 +32,6 @@ test.describe("regression: session timeline context group resize", () => {
const samples = await sampleExpansion(page)
const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
console.log("context resize samples", JSON.stringify(samples, null, 2))
expect(samples[0]?.overlap).toBe(0)
expect(visibleOverlap).toEqual([])
expect(samples.at(-1)?.expanded).toBe("true")
@@ -115,13 +113,15 @@ async function sampleExpansion(page: Page) {
let frame = 1
const tick = () => {
capture(frame, "raf")
frame += 1
if (frame > 8) {
resolve(samples)
return
}
requestAnimationFrame(tick)
setTimeout(() => {
capture(frame, "painted")
frame += 1
if (frame > 8) {
resolve(samples)
return
}
requestAnimationFrame(tick)
}, 0)
}
requestAnimationFrame(tick)
}),
@@ -30,6 +30,284 @@ type SmokeWindow = Window & {
test.describe("smoke: session timeline", () => {
test.setTimeout(240_000)
test("keeps the visible message fixed while prepending history", async ({ page }) => {
const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
messageDelay: 3_000,
onMessages: (input) => requests.push({ before: input.before, phase: input.phase, at: performance.now() }),
})
await configureSmokePage(page, fixture.directory)
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
await waitForTimelineStable(page)
const scroller = timelineScroller(page)
await pointAtTimeline(page)
const deadline = Date.now() + 120_000
while (!requests.some((request) => request.before && request.phase === "start")) {
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
await page.mouse.wheel(0, -240)
await page.waitForTimeout(20)
}
expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
for (let index = 0; index < 12; index++) {
await page.mouse.wheel(0, -120)
await page.waitForTimeout(20)
}
const keys = ["prt_user_text_smoke_0032", "prt_text_2_smoke_0032", "prt_tool_apply_patch_8_smoke_0032"]
const positions = () =>
scroller.evaluate((element, keys) => {
const top = element.getBoundingClientRect().top
return Object.fromEntries(
keys.map((key) => {
const row = element.querySelector<HTMLElement>(`[data-timeline-part-id="${key}"]`)
if (!row) throw new Error(`Missing stable timeline key: ${key}`)
return [key, Math.round((row.getBoundingClientRect().top - top) * devicePixelRatio) / devicePixelRatio]
}),
)
}, keys)
const before = await positions()
expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
await expect.poll(() => requests.some((request) => request.before && request.phase === "end")).toBe(true)
await waitForTimelineStable(page)
await expect.poll(positions).toEqual(before)
})
test("preserves the timeline gap above the composer", async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await configureSmokePage(page, fixture.directory)
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
await waitForTimelineStable(page)
const scroller = timelineScroller(page)
await scroller.evaluate((element) => {
element.scrollTop = element.scrollHeight
})
await waitForTimelineStable(page)
const spacer = scroller.locator('[data-timeline-row="bottom-spacer"]')
await expect(spacer).toBeVisible()
expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(64)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
})
test("paints cached session tabs at the latest message", async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
({ dirBase64, sourceID, targetID }) => {
localStorage.setItem(
"opencode.global.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
server: "http://127.0.0.1:4096",
dirBase64,
sessionId,
})),
),
)
},
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
)
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
await expectSessionTitle(page, fixture.expected.targetTitle)
await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
const last = fixture.expected.targetMessageIDs.at(-1)!
await page.evaluate(
({ destination, last }) => {
const ids = new Set(destination)
const samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> = []
const firstPaintNodes = new WeakSet<Node>()
let firstPaint = false
let removedFirstPaintNodes = 0
let running = true
new MutationObserver((records) => {
if (!firstPaint || !running) return
records.forEach((record) =>
record.removedNodes.forEach((node) => {
if (firstPaintNodes.has(node)) removedFirstPaintNodes += 1
if (!(node instanceof Element)) return
node.querySelectorAll("*").forEach((element) => {
if (firstPaintNodes.has(element)) removedFirstPaintNodes += 1
})
}),
)
}).observe(document.documentElement, { childList: true, subtree: true })
const sample = () => {
if (!running) return
setTimeout(() => {
if (!running) return
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (root) {
const view = root.getBoundingClientRect()
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((element) => element.dataset.messageId!)
.filter((id) => ids.has(id))
const bottom = root
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect()
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
firstPaint = true
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
const rect = row.getBoundingClientRect()
if (rect.bottom <= view.top || rect.top >= view.bottom) return
firstPaintNodes.add(row)
row.querySelectorAll("*").forEach((element) => firstPaintNodes.add(element))
})
}
}
requestAnimationFrame(sample)
}, 0)
}
;(
window as Window & {
__sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
}
).__sessionTabPaint = {
samples,
removed: () => removedFirstPaintNodes,
stop: () => {
running = false
},
}
requestAnimationFrame(sample)
},
{ destination, last },
)
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
await page.waitForFunction(() =>
(
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
)
await page.waitForTimeout(200)
const first = await page.evaluate(() => {
const probe = (
window as Window & {
__sessionTabPaint?: {
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
removed: () => number
stop: () => void
}
}
).__sessionTabPaint!
probe.stop()
return { first: probe.samples.find((sample) => sample.ids.length > 0), removed: probe.removed() }
})
expect(first.first?.last).toBe(true)
expect(Math.abs(first.first?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
expect(first.removed).toBe(0)
})
test("paints a cold session tab at the latest message", async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
({ dirBase64, sourceID, targetID }) => {
localStorage.setItem(
"opencode.global.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
server: "http://127.0.0.1:4096",
dirBase64,
sessionId,
})),
),
)
},
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
)
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
await expectSessionTitle(page, fixture.expected.sourceTitle)
const last = fixture.expected.targetMessageIDs.at(-1)!
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
await page.evaluate(
({ destination, last }) => {
const ids = new Set(destination)
const samples: Array<{ destination: boolean; last: boolean; bottomError?: number }> = []
const sample = () => {
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (root) {
const view = root.getBoundingClientRect()
const spacer = root
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect()
const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
samples.push({
destination: messages.some((element) => ids.has(element.dataset.messageId!)),
last: messages.some((element) => element.dataset.messageId === last),
bottomError: spacer ? spacer.bottom - view.bottom : undefined,
})
}
requestAnimationFrame(() => setTimeout(sample, 0))
}
;(window as Window & { __coldTabSamples?: typeof samples }).__coldTabSamples = samples
requestAnimationFrame(() => setTimeout(sample, 0))
},
{ destination, last },
)
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
await page.waitForFunction(() =>
(window as Window & { __coldTabSamples?: Array<{ destination: boolean }> }).__coldTabSamples?.some(
(sample) => sample.destination,
),
)
const result = await page.evaluate(() => {
const samples = (
window as Window & {
__coldTabSamples?: Array<{ destination: boolean; last: boolean; bottomError?: number }>
}
).__coldTabSamples!
return samples.find((sample) => sample.destination)!
})
expect(result.last).toBe(true)
expect(Math.abs(result.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
})
test("renders seeded timeline in order while paging through history", async ({ page }) => {
const errors = trackPageErrors(page)
await mockOpenCodeServer(page, {
@@ -427,6 +705,14 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
await expectSessionTitle(page, expectedTitle)
}
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
const href = `/${base64Encode(fixture.directory)}/session/${sessionID}`
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
await expectSessionTitle(page, title)
}
async function expectSessionReady(page: Page) {
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
}
+16 -6
View File
@@ -18,7 +18,10 @@ export interface MockServerConfig {
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
messageDelay?: number
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
events?: () => unknown[]
eventRetry?: number
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
@@ -41,10 +44,13 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
if (url.port !== targetPort) return route.fallback()
const appPort = new URL(
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
).port
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.())
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
if (path === "/global/health") return json(route, { healthy: true })
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
@@ -60,13 +66,17 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) {
const limit = Number(url.searchParams.get("limit") ?? 80)
const before = url.searchParams.get("before") ?? undefined
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const limit = Number(url.searchParams.get("limit") ?? 80)
const pageData = config.pageMessages(messagesMatch[1], limit, before)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
}
return json(route, {})
if (url.port === targetPort && targetPort !== appPort) return json(route, {})
return route.fallback()
})
}
@@ -83,10 +93,10 @@ function json(route: Route, body: unknown, headers?: Record<string, string>) {
})
}
function sse(route: Route, events?: unknown[]) {
function sse(route: Route, events?: unknown[], retry?: number) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n",
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
})
}
+8 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.16.2",
"version": "1.17.9",
"description": "",
"type": "module",
"exports": {
@@ -17,13 +17,15 @@
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit",
"test": "bun run test:unit && bun run test:virtualizer",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report"
"test:e2e:report": "playwright show-report e2e/playwright-report",
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
},
"license": "MIT",
"devDependencies": {
@@ -46,6 +48,7 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
@@ -63,6 +66,7 @@
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:",
@@ -75,7 +79,6 @@
"shiki": "catalog:",
"solid-js": "catalog:",
"solid-list": "catalog:",
"tailwindcss": "catalog:",
"virtua": "catalog:"
"tailwindcss": "catalog:"
}
}
+1
View File
@@ -9,6 +9,7 @@ const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
export default defineConfig({
testDir: "./e2e",
testIgnore: process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
outputDir: "./e2e/test-results",
timeout: 60_000,
expect: {
+161 -42
View File
@@ -9,7 +9,7 @@ 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 } from "@solidjs/router"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
@@ -43,25 +43,123 @@ import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider, useSettings } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import { TabsProvider } from "@/context/tabs"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context"
import DirectoryLayout from "@/pages/directory-layout"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const NewSession = lazy(() => import("@/pages/new-session"))
const SessionRoute = Object.assign(
() => (
<SessionProviders>
<Session />
</SessionProviders>
),
() => {
const settings = useSettings()
const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
const sdk = useSDK()
const server = useServer()
const tabs = useTabs()
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
// is replaced by a draft at /new-session?draftId=…
createEffect(() => {
if (!settings.general.newLayoutDesigns()) return
if (params.id || search.draftId) return
if (!tabs.ready() || !sdk().directory) return
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
})
return (
<SessionProviders>
<Session />
</SessionProviders>
)
},
{ preload: Session.preload },
)
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server.
function SelectedServerLayout(props: ParentProps) {
return (
<ServerKey>
<ServerSDKProvider>
<ServerSyncProvider>
<ServerScopedShell>{props.children}</ServerScopedShell>
</ServerSyncProvider>
</ServerSDKProvider>
</ServerKey>
)
}
// Wraps /new-session. It resolves the draft's target server and provides the
// server-scoped shell for that server — without ServerKey, so the page never depends
// on the globally "selected" server.
function DraftServerLayout(props: ParentProps) {
const server = useServer()
const tabs = useTabs()
const [search] = useSearchParams<{ draftId?: string }>()
const conn = createMemo(() => {
const id = search.draftId
if (!id) return undefined
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
if (!draft) return undefined
return server.list.find((c) => ServerConnection.key(c) === draft.server)
})
return (
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ServerScopedShell>{props.children}</ServerScopedShell>
</ServerSyncProvider>
</ServerSDKProvider>
)
}
function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
return (
<Show when={tabs.ready()}>
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
{(draftID) => <ResolvedDraftRoute draftID={draftID} />}
</Show>
</Show>
)
}
function ResolvedDraftRoute(props: { draftID: string }) {
const tabs = useTabs()
const draft = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
)
// Key on the directory so retargeting the draft's project re-instantiates the
// directory-scoped providers while keeping the same draft id. The draft's target
// server is provided by DraftServerLayout, so changing only the server updates the
// SDK/sync hooks without remounting the composer.
const directory = () => draft()?.directory
return (
<Show when={directory()} keyed>
{(dir) => (
<SDKProvider directory={dir}>
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
)}
</Show>
)
}
function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
@@ -108,27 +206,36 @@ function BodyDesignClass() {
return null
}
function AppShellProviders(props: ParentProps) {
// Server-agnostic providers shared across every route. These live in the shared
// shell (router root) so they stay mounted regardless of the active server/route.
function SharedProviders(props: ParentProps) {
return (
<SettingsProvider>
<BodyDesignClass />
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<ModelsProvider>
<CommandProvider>
<HighlightsProvider>
<Layout>{props.children}</Layout>
</HighlightsProvider>
</CommandProvider>
</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
<CommandProvider>
<HighlightsProvider>{props.children}</HighlightsProvider>
</CommandProvider>
</SettingsProvider>
)
}
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
// each per-route server layout so they resolve to that route's server (selected vs
// draft). The Layout remounts when crossing between those groups.
function ServerScopedShell(props: ParentProps) {
return (
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<ModelsProvider>
<Layout>{props.children}</Layout>
</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
)
}
function SessionProviders(props: ParentProps) {
return (
<TerminalProvider>
@@ -141,14 +248,15 @@ function SessionProviders(props: ParentProps) {
)
}
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
// The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) {
return (
<AppShellProviders>
{/*<Suspense fallback={<Loading />}>*/}
{props.appChildren}
{props.children}
{/*</Suspense>*/}
</AppShellProviders>
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</PromptProvider>
</FileProvider>
)
}
@@ -310,6 +418,20 @@ export function AppInterface(props: {
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
}) {
// The shared shell holds only server-agnostic providers (QueryClient + Settings/
// Command/Highlights) and stays mounted across every route. The server-scoped
// providers and the visual Layout live in the per-route layouts below, so they
// resolve to that route's server (selected for most routes, the draft's server for
// /new-session). appChildren is server-agnostic, so it renders here once.
const ServerShell = (shellProps: ParentProps) => (
<QueryProvider>
<SharedProviders>
{props.children}
{shellProps.children}
</SharedProviders>
</QueryProvider>
)
return (
<ServerProvider
defaultServer={props.defaultServer}
@@ -322,22 +444,19 @@ export function AppInterface(props: {
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<ServerKey>
<QueryProvider>
<ServerSDKProvider>
<ServerSyncProvider>
<RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryProvider>
</ServerKey>
<ServerShell>{routerProps.children}</ServerShell>
</TabsProvider>
)}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
<Route component={SelectedServerLayout}>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Route>
<Route component={DraftServerLayout}>
<Route path="/new-session" component={DraftRoute} />
</Route>
</Dynamic>
</ConnectionGate>
@@ -41,7 +41,7 @@ export function DialogConnectProvider(props: { provider: string }) {
})
const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!,
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
)
const fallback = createMemo<ProviderAuthMethod[]>(() => [
{
@@ -52,16 +52,16 @@ export function DialogConnectProvider(props: { provider: string }) {
const [auth] = createResource(
() => props.provider,
async () => {
const cached = serverSync.data.provider_auth[props.provider]
const cached = serverSync().data.provider_auth[props.provider]
if (cached) return cached
const res = await serverSDK.client.provider.auth()
const res = await serverSDK().client.provider.auth()
if (!alive.value) return fallback()
serverSync.set("provider_auth", res.data ?? {})
serverSync().set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback()
},
)
const loading = createMemo(() => auth.loading && !serverSync.data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? serverSync.data.provider_auth[props.provider] ?? fallback())
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
@@ -158,8 +158,8 @@ export function DialogConnectProvider(props: { provider: string }) {
}
dispatch({ type: "auth.pending" })
const start = Date.now()
await serverSDK.client.provider.oauth
.authorize(
await serverSDK()
.client.provider.oauth.authorize(
{
providerID: props.provider,
method: index,
@@ -331,7 +331,7 @@ export function DialogConnectProvider(props: { provider: string }) {
})
async function complete() {
await serverSDK.client.global.dispose()
await serverSDK().client.global.dispose()
dialog.close()
showToast({
variant: "success",
@@ -409,7 +409,7 @@ export function DialogConnectProvider(props: { provider: string }) {
}
setFormStore("error", undefined)
await serverSDK.client.auth.set({
await serverSDK().client.auth.set({
providerID: props.provider,
auth: {
type: "api",
@@ -480,8 +480,8 @@ export function DialogConnectProvider(props: { provider: string }) {
}
setFormStore("error", undefined)
const result = await serverSDK.client.provider.oauth
.callback({
const result = await serverSDK()
.client.provider.oauth.callback({
providerID: props.provider,
method: store.methodIndex,
code,
@@ -533,8 +533,8 @@ export function DialogConnectProvider(props: { provider: string }) {
onMount(() => {
void (async () => {
const result = await serverSDK.client.provider.oauth
.callback({
const result = await serverSDK()
.client.provider.oauth.callback({
providerID: props.provider,
method: store.methodIndex,
})
@@ -105,8 +105,8 @@ export function DialogCustomProvider(props: Props) {
const output = validateCustomProvider({
form,
t: language.t,
disabledProviders: serverSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
disabledProviders: serverSync().data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
})
batch(() => {
setForm("err", output.err)
@@ -118,11 +118,11 @@ export function DialogCustomProvider(props: Props) {
const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
const disabledProviders = serverSync.data.config.disabled_providers ?? []
const disabledProviders = serverSync().data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
if (result.key) {
await serverSDK.client.auth.set({
await serverSDK().client.auth.set({
providerID: result.providerID,
auth: {
type: "api",
@@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
})
}
await serverSync.updateConfig({
await serverSync().updateConfig({
provider: { [result.providerID]: result.config },
disabled_providers: nextDisabled,
})
+7 -7
View File
@@ -35,13 +35,13 @@ export const DialogFork: Component = () => {
const sessionID = params.id
if (!sessionID) return []
const msgs = sync.data.message[sessionID] ?? []
const msgs = sync().data.message[sessionID] ?? []
const result: ForkableMessage[] = []
for (const message of msgs) {
if (message.role !== "user") continue
const parts = sync.data.part[message.id] ?? []
const parts = sync().data.part[message.id] ?? []
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
if (!textPart) continue
@@ -61,15 +61,15 @@ export const DialogFork: Component = () => {
const sessionID = params.id
if (!sessionID) return
const parts = sync.data.part[item.id] ?? []
const parts = sync().data.part[item.id] ?? []
const restored = extractPromptFromParts(parts, {
directory: sdk.directory,
directory: sdk().directory,
attachmentName: language.t("common.attachment"),
})
const dir = base64Encode(sdk.directory)
const dir = base64Encode(sdk().directory)
sdk.client.session
.fork({ sessionID, messageID: item.id })
sdk()
.client.session.fork({ sessionID, messageID: item.id })
.then((forked) => {
if (!forked.data) {
showToast({ title: language.t("common.requestFailed") })
@@ -0,0 +1,107 @@
.directory-picker-v2-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 12px;
padding: 2px 16px 0;
}
.directory-picker-v2-path {
position: relative;
z-index: 10;
display: flex;
gap: 8px;
}
.directory-picker-v2-actions {
display: flex;
flex-shrink: 0;
gap: 2px;
}
.directory-picker-v2-suggestions {
position: absolute;
z-index: 20;
top: 36px;
right: 0;
left: 0;
display: flex;
flex-direction: column;
padding: 4px;
border: 1px solid var(--v2-border-border-base);
border-radius: 6px;
background: var(--v2-background-bg-layer-02);
box-shadow: var(--v2-elevation-overlay);
}
.directory-picker-v2-suggestions button {
overflow: hidden;
padding: 6px 8px;
border-radius: 4px;
color: var(--v2-text-text-muted);
font-size: 12px;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.directory-picker-v2-suggestions button:hover,
.directory-picker-v2-suggestions button[data-active] {
color: var(--v2-text-text-base);
background: var(--v2-overlay-simple-overlay-hover);
}
.directory-picker-v2-browser {
position: relative;
z-index: 0;
isolation: isolate;
min-height: 0;
flex: 1;
overflow: auto;
border: 1px solid var(--v2-border-border-base);
border-radius: 6px;
background: transparent;
}
.directory-picker-v2-tree {
display: block;
width: 100%;
height: 100%;
--trees-bg-override: transparent;
--trees-fg-override: var(--v2-text-text-base);
--trees-fg-muted-override: var(--v2-text-text-muted);
--trees-bg-muted-override: transparent;
--trees-selected-bg-override: transparent;
--trees-selected-fg-override: var(--v2-text-text-base);
--trees-selected-focused-border-color-override: transparent;
--trees-focus-ring-color-override: transparent;
--trees-focus-ring-width-override: 0px;
--trees-focus-ring-offset-override: 0px;
--trees-border-color-override: var(--v2-border-border-base);
--trees-font-family-override: var(--font-family-sans);
--trees-font-size-override: 12px;
--trees-item-height: 24px;
--trees-border-radius-override: 4px;
}
.directory-picker-v2-state {
position: absolute;
z-index: 1;
inset: 0;
display: grid;
place-items: center;
color: var(--v2-text-text-muted);
font-size: 12px;
pointer-events: none;
}
.directory-picker-v2-selection {
overflow: hidden;
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,356 @@
import "@pierre/trees/web-components"
import { FileTree } from "@pierre/trees"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import {
absoluteTreePath,
activeTreeNavigation,
advanceTreePreload,
nextSuggestionIndex,
nextTreeScrollTop,
pickerFileSearchQuery,
pickerAbsoluteInput,
pickerMode,
preloadTreeDirectories,
cleanPickerInput,
createDirectorySearch,
currentPickerSuggestions,
displayPickerPath,
pickerParent,
pickerRoot,
} from "./directory-picker-domain"
import "./dialog-select-directory-v2.css"
interface DialogSelectDirectoryV2Props {
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
server: ServerConnection.Any
mode?: "directory" | "file"
start?: string
}
export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const global = useGlobal()
const { sync, sdk } = global.createServerCtx(props.server)
const dialog = useDialog()
const language = useLanguage()
const policy = pickerMode(props.mode ?? "directory", props.start)
const action = {
file: language.t("dialog.directory.action.selectFile"),
directory: language.t("dialog.directory.action.selectFolder"),
}
const [root, setRoot] = createSignal("")
const [input, setInput] = createSignal("")
const [selected, setSelected] = createSignal("")
const [suggestionsOpen, setSuggestionsOpen] = createSignal(false)
const [activeSuggestion, setActiveSuggestion] = createSignal(-1)
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal(false)
const [rootValid, setRootValid] = createSignal(false)
const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>()
const advanced = new Set<string>()
let tree: FileTree | undefined
let container: HTMLDivElement | undefined
let pathArea: HTMLDivElement | undefined
let navigation = 0
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
() =>
sdk.client.path
.get()
.then((result) => result.data)
.catch(() => undefined),
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const start = createMemo(
() =>
props.start ||
sync.data.path.home ||
sync.data.path.directory ||
fallbackPath()?.home ||
fallbackPath()?.directory,
)
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
const [suggestions] = createResource(input, async (value) => {
const typed = cleanPickerInput(value).replace(/\/+$/, "")
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
if (!typed || typed === current) return { query: value, items: [] }
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.client.find
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
.then((result) => result.data ?? [])
.catch(() => [])
const results = [
...directories,
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
]
return {
query: value,
items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8),
}
})
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
async function load(path: string, generation: number, preload = true) {
const key = path.replace(/\/+$/, "")
setError(false)
const absolute = absoluteTreePath(root(), key)
const request =
listings.get(key) ??
sdk.client.file
.list({ directory: absolute, path: "" })
.then((result) => result.data ?? [])
.catch(() => undefined)
listings.set(key, request)
const nodes = await request
if (!activeTreeNavigation(generation, navigation)) return false
if (!nodes) {
listings.delete(key)
if (!key) setError(true)
return false
}
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
if (preload && advanceTreePreload(advanced, key)) {
void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false)))
}
return true
}
async function navigate(path: string) {
const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home()))
if (!value) return
const token = ++navigation
setLoading(true)
setRootValid(false)
setSelected("")
setSuggestionsOpen(false)
setActiveSuggestion(-1)
setRoot(value)
setInput(displayPickerPath(value, value, home()))
listings.clear()
advanced.clear()
tree?.resetPaths([])
const valid = await load("", token)
if (!activeTreeNavigation(token, navigation)) return
setRootValid(valid)
setLoading(false)
}
function complete() {
const items = currentSuggestions()
const match = items[activeSuggestion()] ?? items[0]
if (!match) return
const value = displayPickerPath(match.absolute, input(), home())
setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value)
if (match.type === "file") {
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "")
setSuggestionsOpen(false)
setActiveSuggestion(-1)
}
}
function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) {
if (suggestion.type === "directory") {
void navigate(suggestion.absolute)
return
}
setInput(displayPickerPath(suggestion.absolute, input(), home()))
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "")
setSuggestionsOpen(false)
setActiveSuggestion(-1)
}
function moveSuggestion(delta: -1 | 1) {
setSuggestionsOpen(true)
setActiveSuggestion((current) => nextSuggestionIndex(current, delta, currentSuggestions().length))
}
function activeSuggestionValue() {
const items = currentSuggestions()
return items[activeSuggestion()] ?? items[0]
}
const keyActions: Partial<Record<string, () => void>> = {
ArrowDown: () => moveSuggestion(1),
ArrowUp: () => moveSuggestion(-1),
Enter: () => {
const suggestion = activeSuggestionValue()
if (suggestion) chooseSuggestion(suggestion)
if (!suggestion) void navigate(input())
},
Tab: complete,
}
function handleInputKey(event: KeyboardEvent) {
const action = keyActions[event.key]
if (!action) return
if (event.key === "Tab" && event.shiftKey) return
event.preventDefault()
action()
}
function resolve() {
const path = policy.result(root(), selected(), rootValid())
if (!path) return
props.onSelect(props.multiple ? [path] : path)
dialog.close()
}
onMount(() => {
const closeSuggestions = (event: PointerEvent) => {
if (pathArea?.contains(event.target as Node)) return
setSuggestionsOpen(false)
setActiveSuggestion(-1)
}
document.addEventListener("pointerdown", closeSuggestions)
onCleanup(() => document.removeEventListener("pointerdown", closeSuggestions))
tree = new FileTree({
paths: [],
flattenEmptyDirectories: false,
initialExpansion: "closed",
stickyFolders: true,
unsafeCSS: `
button[data-type="item"] {
background: transparent !important;
box-shadow: none !important;
}
button[data-type="item"]:hover {
background: var(--v2-overlay-simple-overlay-hover) !important;
}
button[data-type="item"]:focus-visible {
outline: none !important;
box-shadow: none !important;
}
[data-file-tree-virtualized-scroll] {
overscroll-behavior: contain;
scrollbar-width: thin;
}
`,
onExpansionChange(change) {
if (change.expanded) void load(change.path, navigation)
},
onSelectionChange(paths) {
const path = paths.at(-1)
setSelected(path ? (policy.selection(root(), path) ?? "") : "")
},
})
if (!container) return
tree.render({ containerWrapper: container })
tree.getFileTreeContainer()?.classList.add("directory-picker-v2-tree")
})
createEffect(() => {
const path = start()
if (!path || root()) return
void navigate(path)
})
onCleanup(() => tree?.cleanUp())
return (
<Dialog title={props.title ?? language.t("command.project.open")} size="large" class="directory-picker-v2">
<div class="directory-picker-v2-body">
<div class="directory-picker-v2-path" ref={pathArea}>
<TextInputV2
value={input()}
autofocus
autocomplete="off"
spellcheck={false}
class="!w-full"
onInput={(event) => {
setInput(cleanPickerInput(event.currentTarget.value))
setSelected("")
setSuggestionsOpen(true)
setActiveSuggestion(-1)
}}
role="combobox"
aria-autocomplete="list"
aria-expanded={suggestionsOpen()}
aria-controls="directory-picker-v2-suggestions"
aria-activedescendant={
activeSuggestion() >= 0 ? `directory-picker-v2-suggestion-${activeSuggestion()}` : undefined
}
onKeyDown={handleInputKey}
/>
<div class="directory-picker-v2-actions">
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(home())}>
~
</ButtonV2>
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerRoot(root()) || root())}>
{language.t("dialog.directory.root")}
</ButtonV2>
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerParent(root()))}>
{language.t("dialog.directory.parent")}
</ButtonV2>
</div>
<Show when={suggestionsOpen() && currentSuggestions().length > 0}>
<div id="directory-picker-v2-suggestions" role="listbox" class="directory-picker-v2-suggestions">
<For each={currentSuggestions()}>
{(suggestion, index) => (
<button
id={`directory-picker-v2-suggestion-${index()}`}
role="option"
aria-selected={index() === activeSuggestion()}
data-active={index() === activeSuggestion() ? "" : undefined}
onPointerMove={() => setActiveSuggestion(index())}
onClick={() => chooseSuggestion(suggestion)}
>
{displayPickerPath(suggestion.absolute, input(), home())}
{suggestion.type === "directory" ? "/" : ""}
</button>
)}
</For>
</div>
</Show>
</div>
<div
class="directory-picker-v2-browser"
ref={container}
onWheel={(event) => {
const scroller = tree
?.getFileTreeContainer()
?.shadowRoot?.querySelector<HTMLElement>("[data-file-tree-virtualized-scroll]")
if (!scroller) return
const next = nextTreeScrollTop(
scroller.scrollTop,
event.deltaY,
scroller.scrollHeight,
scroller.clientHeight,
)
if (next === scroller.scrollTop) return
event.preventDefault()
scroller.scrollTop = next
scroller.dispatchEvent(new Event("scroll"))
}}
>
<Show when={loading()}>
<div class="directory-picker-v2-state">{language.t("common.loading")}</div>
</Show>
<Show when={!loading() && error()}>
<div class="directory-picker-v2-state">{language.t("dialog.directory.readError")}</div>
</Show>
</div>
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
</div>
<DialogFooter>
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="contrast" disabled={!policy.result(root(), selected(), rootValid())} onClick={resolve}>
{action[policy.action]}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
@@ -4,12 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list"
import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import fuzzysort from "fuzzysort"
import { createMemo, createResource, createSignal } from "solid-js"
import { ServerSDK } from "@/context/server-sdk"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
interface DialogSelectDirectoryProps {
title?: string
@@ -24,89 +23,9 @@ type Row = {
group: "recent" | "folders"
}
function cleanInput(value: string) {
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
}
function normalizePath(input: string) {
const v = input.replaceAll("\\", "/")
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
return v.replace(/\/+/g, "/")
}
function normalizeDriveRoot(input: string) {
const v = normalizePath(input)
if (/^[A-Za-z]:$/.test(v)) return v + "/"
return v
}
function trimTrailing(input: string) {
const v = normalizeDriveRoot(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
return v.replace(/\/+$/, "")
}
function joinPath(base: string | undefined, rel: string) {
const b = trimTrailing(base ?? "")
const r = trimTrailing(rel).replace(/^\/+/, "")
if (!b) return r
if (!r) return b
if (b.endsWith("/")) return b + r
return b + "/" + r
}
function rootOf(input: string) {
const v = normalizeDriveRoot(input)
if (v.startsWith("//")) return "//"
if (v.startsWith("/")) return "/"
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
return ""
}
function parentOf(input: string) {
const v = trimTrailing(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
const i = v.lastIndexOf("/")
if (i <= 0) return "/"
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
return v.slice(0, i)
}
function modeOf(input: string) {
const raw = normalizeDriveRoot(input.trim())
if (!raw) return "relative" as const
if (raw.startsWith("~")) return "tilde" as const
if (rootOf(raw)) return "absolute" as const
return "relative" as const
}
function tildeOf(absolute: string, home: string) {
const full = trimTrailing(absolute)
if (!home) return ""
const hn = trimTrailing(home)
const lc = full.toLowerCase()
const hc = hn.toLowerCase()
if (lc === hc) return "~"
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
return ""
}
function displayPath(path: string, input: string, home: string) {
const full = trimTrailing(path)
if (modeOf(input) === "absolute") return full
return tildeOf(full, home) || full
}
function toRow(absolute: string, home: string, group: Row["group"]): Row {
const full = trimTrailing(absolute)
const tilde = tildeOf(full, home)
const full = displayPickerPath(absolute, "", "")
const tilde = displayPickerPath(full, "~", home)
const withSlash = (value: string) => {
if (!value) return ""
if (value.endsWith("/")) return value
@@ -128,120 +47,6 @@ function uniqueRows(rows: Row[]) {
})
}
function useDirectorySearch(args: { sdk: ServerSDK; start: () => string | undefined; home: () => string }) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0
const scoped = (value: string) => {
const base = args.start()
if (!base) return
const raw = normalizeDriveRoot(value)
if (!raw) return { directory: trimTrailing(base), path: "" }
const h = args.home()
if (raw === "~") return { directory: trimTrailing(h || base), path: "" }
if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) }
const root = rootOf(raw)
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
return { directory: trimTrailing(base), path: raw }
}
const dirs = async (dir: string) => {
const key = trimTrailing(dir)
const existing = cache.get(key)
if (existing) return existing
const request = args.sdk.client.file
.list({ directory: key, path: "" })
.then((x) => x.data ?? [])
.catch(() => [])
.then((nodes) =>
nodes
.filter((n) => n.type === "directory")
.map((n) => ({
name: n.name,
absolute: trimTrailing(normalizeDriveRoot(n.absolute)),
})),
)
cache.set(key, request)
return request
}
const match = async (dir: string, query: string, limit: number) => {
const items = await dirs(dir)
if (!query) return items.slice(0, limit).map((x) => x.absolute)
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
}
return async (filter: string) => {
const token = ++current
const active = () => token === current
const value = cleanInput(filter)
const scopedInput = scoped(value)
if (!scopedInput) return [] as string[]
const raw = normalizeDriveRoot(value)
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
const query = normalizeDriveRoot(scopedInput.path)
const find = () =>
args.sdk.client.find
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
.then((x) => x.data ?? [])
.catch(() => [])
if (!isPath) {
const results = await find()
if (!active()) return []
return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50)
}
const segments = query.replace(/^\/+/, "").split("/")
const head = segments.slice(0, segments.length - 1).filter((x) => x && x !== ".")
const tail = segments[segments.length - 1] ?? ""
const cap = 12
const branch = 4
let paths = [scopedInput.directory]
for (const part of head) {
if (!active()) return []
if (part === "..") {
paths = paths.map(parentOf)
continue
}
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
if (!active()) return []
paths = Array.from(new Set(next)).slice(0, cap)
if (paths.length === 0) return [] as string[]
}
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
if (!active()) return []
const deduped = Array.from(new Set(out))
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
const expand = !raw.endsWith("/")
if (!expand || !tail) {
const items = base ? Array.from(new Set([base, ...deduped])) : deduped
return items.slice(0, 50)
}
const needle = tail.toLowerCase()
const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle)
const target = exact[0]
if (!target) return deduped.slice(0, 50)
const children = await match(target, "", 30)
if (!active()) return []
const items = Array.from(new Set([...deduped, ...children]))
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
}
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const global = useGlobal()
const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server)
@@ -268,10 +73,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
)
const directories = useDirectorySearch({
const directories = createDirectorySearch({
sdk,
home,
start,
base: start,
})
const recentProjects = createMemo(() => {
@@ -336,7 +141,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
}
ref={(r) => (list = r)}
onFilter={(value) => setFilter(cleanInput(value))}
onFilter={(value) => setFilter(cleanPickerInput(value))}
onKeyEvent={(e, item) => {
if (e.key !== "Tab") return
if (e.shiftKey) return
@@ -345,7 +150,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
e.preventDefault()
e.stopPropagation()
const value = displayPath(item.absolute, filter(), home())
const value = displayPickerPath(item.absolute, filter(), home())
list?.setFilter(value.endsWith("/") ? value : value + "/")
}}
onSelect={(path) => {
@@ -354,7 +159,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}}
>
{(item) => {
const path = displayPath(item.absolute, filter(), home())
const path = displayPickerPath(item.absolute, filter(), home())
if (path === "~") {
return (
<div class="w-full flex items-center justify-between rounded-md">
@@ -7,18 +7,25 @@ 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, Match, onCleanup, Show, Switch } from "solid-js"
import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk"
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 { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
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"
const DialogSelectFileV2 = lazy(() =>
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
)
type EntryType = "command" | "file" | "session"
type Entry = {
@@ -175,7 +182,7 @@ function createFileEntries(props: {
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
serverSDK: ReturnType<typeof useServerSDK>
serverSDK: ServerSDK
language: ReturnType<typeof useLanguage>
}) {
const state: {
@@ -261,13 +268,12 @@ function createSessionEntries(props: {
return { sessions }
}
export function DialogSelectFile(props: {
mode?: DialogSelectFileMode
onOpenFile?: (path: string) => void
onSelectFile?: (path: string) => void
}) {
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const server = useServer()
const settings = useSettings()
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
@@ -296,21 +302,21 @@ export function DialogSelectFile(props: {
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync.data.path.home)
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 [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}`
}
const { sessions } = createSessionEntries({ workspaces, label, serverSDK, language })
const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
const items = async (text: string) => {
const query = text.trim()
@@ -379,10 +385,6 @@ export function DialogSelectFile(props: {
}
if (!item.path) return
if (props.onSelectFile) {
props.onSelectFile(item.path)
return
}
open(item.path)
}
@@ -391,6 +393,21 @@ export function DialogSelectFile(props: {
state.cleanup?.()
})
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns() && server.current) {
return (
<DialogSelectFileV2
server={server.current}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
onSelect={(result) => {
if (typeof result !== "string") return
open(result)
}}
/>
)
}
return (
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
<List
@@ -19,7 +19,7 @@ export const DialogSelectMcp: Component = () => {
const language = useLanguage()
const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {})
Object.entries(sync().data.mcp ?? {})
.map(([name, status]) => ({ name, status: status.status }))
.sort((a, b) => a.name.localeCompare(b.name)),
)
@@ -48,7 +48,7 @@ export const DialogSelectMcp: Component = () => {
}}
>
{(i) => {
const mcpStatus = () => sync.data.mcp[i.name]
const mcpStatus = () => sync().data.mcp[i.name]
const status = () => mcpStatus()?.status
const statusLabel = () => {
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
@@ -0,0 +1,189 @@
import { expect, test } from "bun:test"
import {
absoluteTreePath,
activeTreeNavigation,
advanceTreePreload,
nextSuggestionIndex,
nextTreeScrollTop,
pickerTreeEntries,
pickerSearchEntries,
pickerFileSearchQuery,
pickerMode,
preloadTreeDirectories,
selectedTreePath,
treeEntries,
treePathWithin,
currentPickerSuggestions,
createDirectorySearch,
displayPickerPath,
pickerParent,
pickerRoot,
pickerAbsoluteInput,
} from "./directory-picker-domain"
test("maps server directory entries into Pierre paths", () => {
expect(
treeEntries("src/", [
{ name: "components", type: "directory" },
{ name: "index.ts", type: "file" },
]),
).toEqual(["src/components/", "src/index.ts"])
})
test("maps Pierre paths back to the selected server root", () => {
expect(absoluteTreePath("C:/Users/luke", "src/components/")).toBe("C:/Users/luke/src/components")
expect(absoluteTreePath("C:/", "")).toBe("C:/")
expect(absoluteTreePath("C:/", "README.md")).toBe("C:/README.md")
expect(absoluteTreePath("/home/luke", "README.md")).toBe("/home/luke/README.md")
})
test("includes files only when the picker selects files", () => {
const nodes = [
{ name: "components", type: "directory" as const },
{ name: "index.ts", type: "file" as const },
]
expect(pickerTreeEntries("", nodes, "directory")).toEqual(["components/"])
expect(pickerTreeEntries("", nodes, "file")).toEqual(["components/", "index.ts"])
})
test("includes files in file autocomplete while preserving directory navigation", () => {
const nodes = [
{ name: "src", absolute: "/repo/src", type: "directory" as const },
{ name: "README.md", absolute: "/repo/README.md", type: "file" as const },
]
expect(pickerSearchEntries(nodes, "directory")).toEqual([nodes[0]])
expect(pickerSearchEntries(nodes, "file")).toEqual(nodes)
})
test("centralizes file and directory selection policy", () => {
const file = pickerMode("file", "/repo")
expect(file.includeFiles).toBeTrue()
expect(file.selection("/repo/src", "index.ts")).toBe("src/index.ts")
expect(file.selection("/repo", "src/")).toBeUndefined()
expect(file.result("/repo", "src/index.ts")).toBe("src/index.ts")
expect(file.selection("/tmp", "example.txt")).toBeUndefined()
expect(file.navigation("/repo/src")).toBe("/repo/src")
expect(file.navigation("/tmp")).toBeUndefined()
const directory = pickerMode("directory")
expect(directory.includeFiles).toBeFalse()
expect(directory.selection("/repo", "src/")).toBe("/repo/src")
expect(directory.selection("C:/Users/luke", "repos/")).toBe("C:\\Users\\luke\\repos")
expect(directory.selection("//Server/Share", "repo/")).toBe("\\\\Server\\Share\\repo")
expect(directory.navigation("/tmp")).toBe("/tmp")
expect(directory.result("/repo", "")).toBe("/repo")
expect(directory.result("C:/Users/luke", "")).toBe("C:\\Users\\luke")
expect(directory.result("//Server/Share/repo", "")).toBe("\\\\Server\\Share\\repo")
expect(directory.result("/repo", "", false)).toBeUndefined()
})
test("accepts mutations only from the active navigation", () => {
expect(activeTreeNavigation(3, 3)).toBeTrue()
expect(activeTreeNavigation(2, 3)).toBeFalse()
})
test("preserves POSIX case while matching Windows drives case-insensitively", () => {
expect(treePathWithin("/repo", "/Repo")).toBeFalse()
expect(treePathWithin("C:/Repo", "c:/repo/src")).toBeTrue()
expect(treePathWithin("//Server/Share/Repo", "//server/share/repo/src")).toBeTrue()
expect(pickerMode("file", "//Server/Share/Repo").selection("//server/share/repo/src", "file.ts")).toBe("src/file.ts")
expect(treePathWithin("/repo", "/repo/../tmp")).toBeFalse()
expect(treePathWithin("/", "/src")).toBeTrue()
expect(pickerMode("file", "C:/Repo").selection("c:/repo/src", "file.ts")).toBe("src/file.ts")
expect(pickerMode("file", "C:/").selection("C:/", "file.ts")).toBe("file.ts")
})
test("displays paths using the selected server path format", () => {
expect(displayPickerPath("C:/Users/luke/repos", "C:/Users/luke/repos", "C:/Users/luke")).toBe(
"C:\\Users\\luke\\repos",
)
expect(displayPickerPath("C:/Users/luke/repos", "C:\\Users\\luke\\repos", "C:/Users/luke")).toBe(
"C:\\Users\\luke\\repos",
)
expect(displayPickerPath("/home/luke/repos", "repos", "/home/luke")).toBe("~/repos")
expect(displayPickerPath("/home/luke/repos", "~/repos", "/home/luke")).toBe("~/repos")
})
test("treats the server share prefix as the UNC root", () => {
expect(pickerRoot("//Server/Share/repo/src")).toBe("//Server/Share")
expect(pickerRoot("\\\\Server\\Share\\repo\\src")).toBe("//Server/Share")
expect(pickerParent("//Server/Share")).toBe("//Server/Share")
expect(pickerParent("//Server/Share/repo")).toBe("//Server/Share")
})
test("resolves relative input against the current picker root", () => {
expect(pickerAbsoluteInput("src", "/home/luke", "/home/luke/repo")).toBe("/home/luke/repo/src")
expect(pickerAbsoluteInput("../other", "/home/luke", "/home/luke/repo")).toBe("/home/luke/other")
expect(pickerAbsoluteInput("~/.config", "/home/luke", "/home/luke/repo")).toBe("/home/luke/.config")
expect(pickerAbsoluteInput("src", "C:/Users/luke", "C:/Users/luke/repo")).toBe("C:/Users/luke/repo/src")
})
test("exposes autocomplete results only for their source query", () => {
const result = { query: "/repo/src", items: ["/repo/src/index.ts"] }
expect(currentPickerSuggestions(result, "/repo/src")).toEqual(result.items)
expect(currentPickerSuggestions(result, "/repo/test")).toEqual([])
})
test("scopes file autocomplete to the current browser root", () => {
expect(pickerFileSearchQuery("/home/luke/repos", "/home/luke/repos/src/in", "/home/luke")).toBe("src/in")
expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op")
})
test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = []
const sdk = {
client: {
find: {
files: (input: { directory: string }) => {
directories.push(input.directory)
return Promise.resolve({ data: [] })
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
let base = "/repo"
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
await search("components")
base = "/repo/src"
await search("components")
expect(directories).toEqual(["/repo", "/repo/src"])
})
test("identifies the next directory level to preload", () => {
expect(
preloadTreeDirectories("src/", [
{ name: "components", type: "directory" },
{ name: "index.ts", type: "file" },
{ name: "utils", type: "directory" },
]),
).toEqual(["src/components/", "src/utils/"])
})
test("advances preloading once for every expanded directory", () => {
const advanced = new Set<string>()
expect(advanceTreePreload(advanced, "")).toBeTrue()
expect(advanceTreePreload(advanced, "")).toBeFalse()
expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
})
test("clamps bridged tree wheel scrolling", () => {
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
expect(nextTreeScrollTop(290, 40, 500, 200)).toBe(300)
})
test("wraps autocomplete keyboard navigation", () => {
expect(nextSuggestionIndex(-1, 1, 4)).toBe(0)
expect(nextSuggestionIndex(3, 1, 4)).toBe(0)
expect(nextSuggestionIndex(0, -1, 4)).toBe(3)
expect(nextSuggestionIndex(0, 1, 0)).toBe(-1)
})
test("returns absolute directories and relative files", () => {
expect(selectedTreePath("/home/luke/repo", "src/", "directory")).toBe("/home/luke/repo/src")
expect(selectedTreePath("/home/luke/repo", "src/index.ts", "file")).toBe("src/index.ts")
expect(selectedTreePath("/home/luke/repo/src", "index.ts", "file", "/home/luke/repo")).toBe("src/index.ts")
expect(selectedTreePath("/home/luke/repo", "src/", "file")).toBeUndefined()
})
@@ -0,0 +1,331 @@
export function treeEntries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
const prefix = parent.replace(/^\/+|\/+$/g, "")
return nodes.map((node) => {
const path = prefix ? `${prefix}/${node.name}` : node.name
return node.type === "directory" ? path + "/" : path
})
}
export function pickerTreeEntries(
parent: string,
nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>,
mode: "directory" | "file",
) {
return treeEntries(parent, mode === "directory" ? nodes.filter((node) => node.type === "directory") : nodes)
}
export function pickerSearchEntries<T extends { type: "file" | "directory" }>(
nodes: readonly T[],
mode: "directory" | "file",
) {
return mode === "directory" ? nodes.filter((node) => node.type === "directory") : [...nodes]
}
export function pickerMode(mode: "directory" | "file", base?: string) {
if (mode === "file") {
return {
includeFiles: true,
action: "file" as const,
entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
return treeEntries(parent, nodes)
},
navigation(path: string) {
return treePathWithin(base, path) ? path : undefined
},
result(root: string, selected: string) {
return selected || undefined
},
selection(root: string, path: string) {
if (!treePathWithin(base, root)) return
return selectedTreePath(root, path, "file", base)
},
}
}
return {
includeFiles: false,
action: "directory" as const,
entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
return treeEntries(
parent,
nodes.filter((node) => node.type === "directory"),
)
},
navigation(path: string) {
return path
},
result(root: string, selected: string, valid = true) {
if (!valid) return
return selected || (root ? nativePickerPath(root) : undefined)
},
selection(root: string, path: string) {
return selectedTreePath(root, path, "directory")
},
}
}
export function pickerFileSearchQuery(root: string, input: string, home: string) {
const value = input
.replace(/\\/g, "/")
.replace(/^~(?=\/|$)/, home)
.replace(/\/+$/, "")
const base = root.replace(/\\/g, "/").replace(/\/+$/, "")
if (value === base) return ""
if (value.startsWith(base + "/")) return value.slice(base.length + 1)
return value
}
export function pickerAbsoluteInput(input: string, home: string, current: string) {
const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home))
const absolute = pickerRoot(value) ? value : joinPickerPath(current, value)
return canonicalPickerPath(absolute)
}
export function treePathWithin(base: string | undefined, path: string) {
return pickerRelativePath(base, path) !== undefined
}
export function canonicalPickerPath(path: string) {
const value = normalizePickerDrive(path)
const root = pickerRoot(value)
const parts = value.slice(root.length).split("/")
const resolved = parts.reduce<string[]>((output, part) => {
if (!part || part === ".") return output
if (part === "..") {
output.pop()
return output
}
output.push(part)
return output
}, [])
return joinPickerPath(root, resolved.join("/"))
}
export function pickerRelativePath(base: string | undefined, path: string) {
if (!base) return
const rootPath = canonicalPickerPath(base)
const targetPath = canonicalPickerPath(path)
const insensitive = /^[A-Za-z]:\//.test(rootPath) || rootPath.startsWith("//")
const root = insensitive ? rootPath.toLowerCase() : rootPath
const target = insensitive ? targetPath.toLowerCase() : targetPath
if (target === root) return ""
const prefix = root.endsWith("/") ? root : root + "/"
if (!target.startsWith(prefix)) return
return targetPath.slice(prefix.length)
}
export function currentPickerSuggestions<T>(result: { query: string; items: readonly T[] } | undefined, query: string) {
if (result?.query !== query) return []
return result.items
}
export function preloadTreeDirectories(
parent: string,
nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>,
) {
return treeEntries(
parent,
nodes.filter((node) => node.type === "directory"),
)
}
export function advanceTreePreload(advanced: Set<string>, path: string) {
if (advanced.has(path)) return false
advanced.add(path)
return true
}
export function activeTreeNavigation(request: number, current: number) {
return request === current
}
export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) {
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
}
export function nextSuggestionIndex(current: number, delta: -1 | 1, count: number) {
if (count === 0) return -1
return (current + delta + count) % count
}
export function absoluteTreePath(root: string, path: string) {
const base = trimPickerPath(root)
const relative = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
if (!relative) return base || "/"
if (!base || base === "/") return "/" + relative
if (base.endsWith("/")) return base + relative
return `${base}/${relative}`
}
export function selectedTreePath(root: string, path: string, mode: "directory" | "file", base?: string) {
const directory = path.endsWith("/")
if (mode === "file") {
if (directory) return
if (!base) return path
const absolute = absoluteTreePath(root, path)
return pickerRelativePath(base, absolute)
}
return directory ? nativePickerPath(absoluteTreePath(root, path)) : undefined
}
export function nativePickerPath(path: string) {
const value = trimPickerPath(path)
if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\")
return value
}
import { getFilename } from "@opencode-ai/core/util/path"
import fuzzysort from "fuzzysort"
import { ServerSDK } from "@/context/server-sdk"
export function cleanPickerInput(value: string) {
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
}
export function normalizePickerPath(input: string) {
const value = input.replaceAll("\\", "/")
if (value.startsWith("//") && !value.startsWith("///")) return "//" + value.slice(2).replace(/\/+/g, "/")
return value.replace(/\/+/g, "/")
}
export function normalizePickerDrive(input: string) {
const value = normalizePickerPath(input)
if (/^[A-Za-z]:$/.test(value)) return value + "/"
return value
}
export function trimPickerPath(input: string) {
const value = normalizePickerDrive(input)
if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value
return value.replace(/\/+$/, "")
}
export function joinPickerPath(base: string | undefined, relative: string) {
const root = trimPickerPath(base ?? "")
const path = trimPickerPath(relative).replace(/^\/+/, "")
if (!root) return path
if (!path) return root
if (root.endsWith("/")) return root + path
return root + "/" + path
}
export function pickerRoot(input: string) {
const value = normalizePickerDrive(input)
if (value.startsWith("//")) {
const [server, share] = value.slice(2).split("/")
if (server && share) return `//${server}/${share}`
return "//"
}
if (value.startsWith("/")) return "/"
if (/^[A-Za-z]:\//.test(value)) return value.slice(0, 3)
return ""
}
export function pickerParent(input: string) {
const value = trimPickerPath(input)
const root = pickerRoot(value)
if (value === root) return value
if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value
const index = value.lastIndexOf("/")
if (index < root.length) return root
if (index <= 0) return "/"
if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3)
return value.slice(0, index)
}
function pickerTilde(absolute: string, home: string) {
const path = trimPickerPath(absolute)
if (!home) return ""
const root = trimPickerPath(home)
if (/^[A-Za-z]:\//.test(root)) return ""
if (path === root) return "~"
if (path.startsWith(root + "/")) return "~" + path.slice(root.length)
return ""
}
export function displayPickerPath(path: string, input: string, home: string) {
const value = trimPickerPath(path)
if (/^[A-Za-z]:\//.test(trimPickerPath(home)) || /^[A-Za-z]:\//.test(value)) return value.replaceAll("/", "\\")
return pickerTilde(value, home) || value
}
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0
const scoped = (value: string) => {
const base = args.base()
if (!base) return
const raw = normalizePickerDrive(value)
if (!raw) return { directory: trimPickerPath(base), path: "" }
const home = args.home()
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
const root = pickerRoot(raw)
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
return { directory: trimPickerPath(base), path: raw }
}
const directories = async (directory: string) => {
const key = trimPickerPath(directory)
const existing = cache.get(key)
if (existing) return existing
const request = args.sdk.client.file
.list({ directory: key, path: "" })
.then((result) => result.data ?? [])
.catch(() => [])
.then((nodes) =>
nodes
.filter((node) => node.type === "directory")
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
)
cache.set(key, request)
return request
}
const match = async (directory: string, query: string, limit: number) => {
const items = await directories(directory)
if (!query) return items.slice(0, limit).map((item) => item.absolute)
return fuzzysort.go(query, items, { key: "name", limit }).map((item) => item.obj.absolute)
}
return async (filter: string) => {
const token = ++current
const active = () => token === current
const value = cleanPickerInput(filter)
const input = scoped(value)
if (!input) return [] as string[]
const raw = normalizePickerDrive(value)
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path)
if (!pathInput) {
const results = await args.sdk.client.find
.files({ directory: input.directory, query, type: "directory", limit: 50 })
.then((result) => result.data ?? [])
.catch(() => [])
if (!active()) return []
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
}
const segments = query.replace(/^\/+/, "").split("/")
const head = segments.slice(0, -1).filter((part) => part && part !== ".")
const tail = segments.at(-1) ?? ""
let paths = [input.directory]
for (const part of head) {
if (!active()) return []
if (part === "..") {
paths = paths.map(pickerParent)
continue
}
paths = Array.from(new Set((await Promise.all(paths.map((path) => match(path, part, 4)))).flat())).slice(0, 12)
if (!active() || paths.length === 0) return []
}
const matches = Array.from(new Set((await Promise.all(paths.map((path) => match(path, tail, 50)))).flat()))
if (!active()) return []
const base = raw.startsWith("~") ? trimPickerPath(input.directory) : ""
if (raw.endsWith("/") || !tail) return Array.from(new Set([base, ...matches].filter(Boolean))).slice(0, 50)
const target = matches.find((path) => getFilename(path).toLowerCase() === tail.toLowerCase())
if (!target) return matches.slice(0, 50)
const children = await match(target, "", 30)
if (!active()) return []
return Array.from(new Set([base, ...matches, ...children].filter(Boolean))).slice(0, 50)
}
}
@@ -1,9 +1,15 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ServerConnection } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { lazy } from "solid-js"
import { DialogSelectDirectory } from "./dialog-select-directory"
import { directoryPickerKind } from "./directory-picker-policy"
const DialogSelectDirectoryV2 = lazy(() =>
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
)
type DirectoryPickerInput = {
server: ServerConnection.Any
title?: string
@@ -13,6 +19,7 @@ type DirectoryPickerInput = {
export function useDirectoryPicker() {
const platform = usePlatform()
const settings = useSettings()
const dialog = useDialog()
return (input: DirectoryPickerInput) => {
@@ -21,9 +28,18 @@ export function useDirectoryPicker() {
return
}
dialog.show(
() => <DialogSelectDirectory {...input} />,
() => input.onSelect(null),
)
let selected = false
const onSelect = (result: string | string[] | null) => {
selected = result !== null
input.onSelect(result)
}
const cancel = () => {
if (!selected) input.onSelect(null)
}
if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
return
}
dialog.show(() => <DialogSelectDirectory {...input} onSelect={onSelect} />, cancel)
}
}
@@ -8,6 +8,8 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
mock.module("@/context/file", () => ({
useFile: () => ({
@@ -0,0 +1,23 @@
import { expect, test } from "bun:test"
import { FileTree, type FileTreeDirectoryHandle } from "@pierre/trees"
test("reports directory expansion changes", () => {
const changes: Array<{ path: string; expanded: boolean }> = []
const tree = new FileTree({
paths: ["src/"],
onExpansionChange: (change) => changes.push(change),
})
const src = tree.getItem("src/")
if (!src || !src.isDirectory()) throw new Error("Expected src to be a directory")
const directory = src as FileTreeDirectoryHandle
directory.expand()
directory.collapse()
expect(changes).toEqual([
{ path: "src/", expanded: true },
{ path: "src/", expanded: false },
])
tree.cleanUp()
})
@@ -0,0 +1,117 @@
// @ts-nocheck
import { createStore } from "solid-js/store"
import { createPromptState } from "@/context/prompt"
import { createPromptInputHistory, PromptInput } from "./prompt-input"
function PromptInputExample() {
const state = createPromptState()
const history = createPromptInputHistory()
const [controls, setControls] = createStore({
agent: "build",
variant: undefined as string | undefined,
comments: 0,
tabs: [] as string[],
activeTab: undefined as string | undefined,
reviewOpen: false,
})
const model = {
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
variant: {
list: () => ["fast", "thinking"],
current: () => controls.variant,
set: (variant?: string) => setControls("variant", variant),
},
}
const submission = {
abort() {},
handleSubmit(event: Event) {
event.preventDefault()
state.reset()
},
}
const inputControls = {
agents: {
available: [{ name: "review", hidden: false, mode: "subagent" }],
options: ["build", "review", "plan"],
get current() {
return controls.agent
},
loading: false,
visible: true,
select: (agent?: string) => setControls("agent", agent ?? "build"),
},
model: {
selection: model,
paid: true,
loading: false,
},
projects: {
available: [{ name: "Story project", worktree: "/tmp/story", sandboxes: [] }],
directory: "/tmp/story",
select() {},
add() {},
},
session: {
id: "story-session",
tabs: {
active: () => controls.activeTab,
all: () => controls.tabs,
open: (tab: string) => setControls("tabs", (tabs) => (tabs.includes(tab) ? tabs : [...tabs, tab])),
setActive: (tab: string) => setControls("activeTab", tab),
},
reviewPanel: {
opened: () => controls.reviewOpen,
open: () => setControls("reviewOpen", true),
},
},
newLayoutDesigns: true,
}
const addReviewComment = () => {
const comment = controls.comments + 1
setControls("comments", comment)
state.context.add({
type: "file",
path: "src/components/prompt-input.tsx",
selection: {
startLine: 84 + comment,
startChar: 0,
endLine: 84 + comment,
endChar: 0,
},
comment: `Review comment ${comment}`,
commentID: `review-comment-${comment}`,
commentOrigin: "review",
preview: "export const PromptInput = ...",
})
}
return (
<div class="flex flex-col gap-3">
<PromptInput controls={inputControls} state={state} history={history} submission={submission} />
<div>
<button
type="button"
class="rounded-md border border-border-weak-base bg-background-base px-2.5 py-1.5 text-12-medium text-text-base hover:bg-background-stronger"
onClick={addReviewComment}
>
Add review comment
</button>
</div>
</div>
)
}
export default {
title: "App/PromptInput",
id: "app-prompt-input",
component: PromptInput,
}
export const Basic = {
render: () => (
<div class="pt-10">
<h1 class="mb-4">Prompt Input</h1>
<PromptInputExample />
</div>
),
}
+193 -167
View File
@@ -17,8 +17,8 @@ import {
type JSX,
} from "solid-js"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
import {
ContentPart,
@@ -31,9 +31,7 @@ import {
FileAttachmentPart,
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useNavigate } from "@solidjs/router"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button"
@@ -45,15 +43,11 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { serverAttachmentFile } from "./prompt-input/server-attachment"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
@@ -73,18 +67,104 @@ import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder"
import { useDirectoryPicker } from "./directory-picker"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useQueries } from "@tanstack/solid-query"
import { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { displayName } from "@/pages/layout/helpers"
interface PromptInputProps {
export type PromptInputState = ReturnType<typeof usePrompt>
export type PromptInputHistory = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
}
export type PromptInputSubmission = {
abort: () => Promise<void> | void
handleSubmit: (event: Event) => Promise<void> | void
}
export type PromptInputControls = {
agents: {
available: { name: string; hidden?: boolean; mode: string }[]
options: string[]
current: string
loading: boolean
visible: boolean
select: (name: string | undefined) => void
}
model: {
selection: ReturnType<typeof useLocal>["model"]
paid: boolean
loading: boolean
}
projects: {
available: { name?: string; worktree: string; sandboxes?: string[] }[]
directory: string
select: (worktree: string) => void
add: (title: string) => void
}
session: {
id?: string
tabs: {
active: () => string | undefined
all: () => string[]
open: (tab: string) => void | Promise<void>
setActive: (tab: string) => void
}
reviewPanel: {
opened: () => boolean
open: () => void
}
}
newLayoutDesigns: boolean
}
export function createPromptInputHistory(): PromptInputHistory {
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
function createPromptInputHistoryStore(
normal: Store<PromptHistoryState>,
setNormal: SetStoreFunction<PromptHistoryState>,
shell: Store<PromptHistoryState>,
setShell: SetStoreFunction<PromptHistoryState>,
): PromptInputHistory {
return {
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
add(prompt, mode, comments) {
const current = mode === "shell" ? shell : normal
const setCurrent = mode === "shell" ? setShell : setNormal
const next = prependHistoryEntry(current.entries, prompt, comments)
if (next === current.entries) return
setCurrent("entries", next)
},
}
}
function createPersistedPromptInputHistory() {
const [normal, setNormal] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<PromptHistoryState>({ entries: [] }),
)
const [shell, setShell] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<PromptHistoryState>({ entries: [] }),
)
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}
export interface PromptInputProps {
class?: string
variant?: "dock" | "new-session"
state?: PromptInputState
history?: PromptInputHistory
submission?: PromptInputSubmission
controls: PromptInputControls
ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void
@@ -126,25 +206,18 @@ const EXAMPLES = [
export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK()
const navigate = useNavigate()
const queryOptions = useQueryOptions()
const sync = useSync()
const local = useLocal()
const files = useFile()
const prompt = usePrompt()
const prompt = props.state ?? usePrompt()
const layout = useLayout()
const server = useServer()
const comments = useComments()
const dialog = useDialog()
const providers = useProviders()
const command = useCommand()
const permission = usePermission()
const language = useLanguage()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const settings = useSettings()
const { params, tabs, view } = useSessionLayout()
const tabs = () => props.controls.session.tabs
let editorRef!: HTMLDivElement
let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement
@@ -202,10 +275,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}).activeFileTab
const commentInReview = (path: string) => {
const sessionID = params.id
const sessionID = props.controls.session.id
if (!sessionID) return false
const diffs = sync.data.session_diff[sessionID]
const diffs = sync().data.session_diff[sessionID]
if (!diffs) return false
return diffs.some((diff) => diff.file === path)
}
@@ -235,14 +308,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
if (wantsReview) {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
layout.fileTree.setTab("changes")
tabs().setActive("review")
queueCommentFocus()
return
}
if (!view().reviewPanel.opened()) view().reviewPanel.open()
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
layout.fileTree.setTab("all")
const tab = files.tab(item.path)
void tabs().open(tab)
@@ -267,8 +340,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return paths
})
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const working = createMemo(() => sync.data.session_working(params.id ?? ""))
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
@@ -345,29 +418,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const hasUserPrompt = createMemo(() => {
const sessionID = params.id
const sessionID = props.controls.session.id
if (!sessionID) return false
const messages = sync.data.message[sessionID]
const messages = sync().data.message[sessionID]
if (!messages) return false
return messages.some((m) => m.role === "user")
})
const [history, setHistory] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<{
entries: PromptHistoryStoredEntry[]
}>({
entries: [],
}),
)
const [shellHistory, setShellHistory] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<{
entries: PromptHistoryStoredEntry[]
}>({
entries: [],
}),
)
const history = props.history ?? createPersistedPromptInputHistory()
const suggest = createMemo(() => !hasUserPrompt())
@@ -470,34 +528,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
const pick = () => {
if (server.isLocal()) {
pickAttachmentFiles({
picker: platform.openAttachmentPickerDialog,
directory: () => sdk.directory,
fallback: () => fileInputRef?.click(),
onFile: addAttachment,
onError: (error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
})
return
}
void import("@/components/dialog-select-file").then((module) =>
dialog.show(() => (
<module.DialogSelectFile
mode="files"
onSelectFile={(path) => {
void sdk.client.v2.fs
.read({ path })
.then((response) => response.data?.data)
.then((data) => data && addAttachments([serverAttachmentFile(path, data)]))
}}
/>
)),
)
pickAttachmentFiles({
picker: platform.openAttachmentPickerDialog,
directory: () => sdk().directory,
fallback: () => fileInputRef?.click(),
onFile: addAttachment,
onError: (error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
})
}
const setMode = (mode: "normal" | "shell") => {
@@ -587,8 +629,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
createEffect(() => {
params.id
if (params.id) return
props.controls.session.id
if (props.controls.session.id) return
if (!suggest()) return
const interval = setInterval(() => {
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
@@ -617,11 +659,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const agentList = createMemo(() =>
sync.data.agent
props.controls.agents.available
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
)
const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
const handleAtSelect = (option: AtOption | undefined) => {
if (!option) return
@@ -687,7 +728,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type: "builtin" as const,
}))
const custom = sync.data.command.map((cmd) => ({
const custom = sync().data.command.map((cmd) => ({
id: `custom.${cmd.name}`,
trigger: cmd.name,
title: cmd.name,
@@ -1047,11 +1088,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
const currentHistory = mode === "shell" ? shellHistory : history
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments())
if (next === currentHistory.entries) return
setCurrentHistory("entries", next)
history.add(prompt, mode, mode === "shell" ? [] : historyComments())
}
createEffect(
@@ -1096,7 +1133,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const navigateHistory = (direction: "up" | "down") => {
const result = navigatePromptHistory({
direction,
entries: store.mode === "shell" ? shellHistory.entries : history.entries,
entries: history.entries(store.mode),
historyIndex: store.historyIndex,
currentPrompt: prompt.current(),
currentComments: historyComments(),
@@ -1110,6 +1147,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
prompt,
editor: () => editorRef,
isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type),
@@ -1119,6 +1157,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
addPart,
readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile,
})
const fileAttachmentInput = () => (
@@ -1136,38 +1175,41 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>
)
const variants = createMemo(() => ["default", ...local.model.variant.list()])
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
// Check provider variants directly: `variants` also includes the UI-only default option.
const showVariantControl = createMemo(() => local.model.variant.list().length > 0)
const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0)
const accepting = createMemo(() => {
const id = params.id
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
return permission.isAutoAccepting(id, sdk.directory)
const id = props.controls.session.id
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
return permission.isAutoAccepting(id, sdk().directory)
})
const { abort, handleSubmit } = createPromptSubmit({
info,
imageAttachments,
commentCount,
autoAccept: () => accepting(),
mode: () => store.mode,
working,
editor: () => editorRef,
queueScroll,
promptLength,
addToHistory,
resetHistoryNavigation: () => {
resetHistoryNavigation(true)
},
setMode: (mode) => setStore("mode", mode),
setPopover: (popover) => setStore("popover", popover),
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
})
const { abort, handleSubmit } =
props.submission ??
createPromptSubmit({
prompt,
info,
imageAttachments,
commentCount,
autoAccept: () => accepting(),
mode: () => store.mode,
working,
editor: () => editorRef,
queueScroll,
promptLength,
addToHistory,
resetHistoryNavigation: () => {
resetHistoryNavigation(true)
},
setMode: (mode) => setStore("mode", mode),
setPopover: (popover) => setStore("popover", popover),
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
})
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
@@ -1331,17 +1373,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({
queries: [
queryOptions.agents(pathKey(sdk.directory)),
queryOptions.providers(null),
queryOptions.providers(pathKey(sdk.directory)),
],
}))
const agentsLoading = () => agentsQuery.isLoading
const agentsLoading = () => props.controls.agents.loading
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading
const providersLoading = () => props.controls.model.loading
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
const [promptReady] = createResource(
@@ -1356,23 +1390,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const modelControlState = createMemo<ComposerModelControlState>(() => ({
loading: providersLoading(),
paid: providers.paid().length > 0,
paid: props.controls.model.paid,
title: language.t("command.model.choose"),
keybind: command.keybind("model.choose"),
model: local.model,
providerID: local.model.current()?.provider?.id,
modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"),
model: props.controls.model.selection,
providerID: props.controls.model.selection.current()?.provider?.id,
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
style: control(),
onClose: restoreFocus,
onUnpaidClick: () => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
dialog.show(() => <x.DialogSelectModelUnpaid model={props.controls.model.selection} />)
})
},
}))
const newSession = () => props.variant === "new-session"
const projects = createMemo(() => layout.projects.list())
const projects = createMemo(() => props.controls.projects.available)
const projectForDirectory = (directory: string | undefined) => {
if (!directory) return
const key = pathKey(directory)
@@ -1380,13 +1414,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
(project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
)
}
const selectedProject = createMemo(() => projectForDirectory(sdk.directory))
const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory))
const projectResults = createMemo(() => {
const search = picker.projectSearch.trim().toLowerCase()
if (!search) return projects()
return projects().filter((project) => displayName(project).toLowerCase().includes(search))
})
const showAgentControl = createMemo(() => settings.general.showCustomAgents() && agentNames().length > 0)
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
const selectProject = (worktree: string) => {
setPicker({
projectOpen: false,
@@ -1396,23 +1430,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
restoreFocus()
return
}
layout.projects.open(worktree)
server.projects.touch(worktree)
navigate(`/${base64Encode(worktree)}/session`)
props.controls.projects.select(worktree)
restoreFocus()
}
const addProject = () => {
const conn = server.current
if (!conn) return
const select = (result: string | string[] | null) => {
const directory = Array.isArray(result) ? result[0] : result
if (!directory) return
selectProject(directory)
}
pickDirectory({
server: conn,
title: language.t("command.project.open"),
onSelect: select,
})
props.controls.projects.add(language.t("command.project.open"))
}
const projectPickerState = createMemo<ComposerPickerState>(() => ({
@@ -1453,11 +1475,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
title: language.t("command.agent.cycle"),
keybind: command.keybind("agent.cycle"),
options: agentNames(),
current: local.agent.current()?.name ?? "",
options: props.controls.agents.options,
current: props.controls.agents.current,
style: control(),
onSelect: (value) => {
local.agent.set(value)
props.controls.agents.select(value)
restoreFocus()
},
}))
@@ -1489,7 +1511,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<Switch>
<Match when={settings.general.newLayoutDesigns()}>
<Match when={props.controls.newLayoutDesigns}>
<div class="flex flex-col gap-3">
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
@@ -1609,7 +1631,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
data-component="prompt-variant-control"
classList={{
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
!local.model.variant.current() && !store.variantOpen,
!props.controls.model.selection.variant.current() && !store.variantOpen,
}}
>
<TooltipKeybind
@@ -1621,11 +1643,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
current={props.controls.model.selection.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onOpenChange={(open) => setStore("variantOpen", open)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
@@ -1865,10 +1887,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<Select
size="normal"
options={agentNames()}
current={local.agent.current()?.name ?? ""}
options={props.controls.agents.options}
current={props.controls.agents.current}
onSelect={(value) => {
local.agent.set(value)
props.controls.agents.select(value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base"
@@ -1887,7 +1909,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<Show
when={providers.paid().length > 0}
when={props.controls.model.paid}
fallback={
<TooltipKeybind
placement="top"
@@ -1904,19 +1926,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
dialog.show(() => (
<x.DialogSelectModelUnpaid model={props.controls.model.selection} />
))
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<Show when={props.controls.model.selection.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
id={props.controls.model.selection.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
{props.controls.model.selection.current()?.name ??
language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
@@ -1930,7 +1955,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
model={local.model}
model={props.controls.model.selection}
triggerAs={Button}
triggerProps={{
variant: "ghost",
@@ -1941,15 +1966,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
onClose={restoreFocus}
>
<Show when={local.model.current()?.provider?.id}>
<Show when={props.controls.model.selection.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
id={props.controls.model.selection.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
{props.controls.model.selection.current()?.name ??
language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
@@ -1970,10 +1996,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
current={props.controls.model.selection.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base"
@@ -1,7 +1,7 @@
import { onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@/utils/toast"
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
import { useLanguage } from "@/context/language"
import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom"
@@ -26,16 +26,18 @@ function dataUrl(file: File, mime: string) {
}
type PromptAttachmentsInput = {
prompt: ReturnType<typeof usePrompt>
editor: () => HTMLDivElement | undefined
isDialogActive: () => boolean
setDraggingType: (type: "image" | "@mention" | null) => void
focusEditor: () => void
addPart: (part: ContentPart) => boolean
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
}
export function createPromptAttachments(input: PromptAttachmentsInput) {
const prompt = usePrompt()
const prompt = input.prompt
const language = useLanguage()
const warn = () => {
@@ -62,6 +64,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
type: "image",
id: uuid(),
filename: file.name,
sourcePath: input.getPathForFile?.(file) || undefined,
mime,
dataUrl: url,
}
@@ -75,6 +75,31 @@ describe("buildRequestParts", () => {
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
})
test("preserves an external attachment source path for the model", () => {
const result = buildRequestParts({
prompt: [],
context: [],
images: [
{
type: "image",
id: "img_external",
filename: "opencode.global.dat",
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
mime: "text/plain",
dataUrl: "data:text/plain;base64,AAA",
},
],
text: "inspect this",
messageID: "msg_external",
sessionID: "ses_external",
sessionDirectory: "C:\\Repos\\sst\\opencode",
})
expect(result.requestParts.find((part) => part.type === "file")?.filename).toBe(
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
)
})
test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
@@ -188,7 +188,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
type: "file",
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
filename: attachment.sourcePath ?? attachment.filename,
} satisfies PromptRequestPart
})
@@ -50,7 +50,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
>
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0 font-medium">
<div class="flex items-center text-[12px] min-w-0 font-medium leading-5">
<span class="text-text-strong whitespace-nowrap">{label}</span>
<Show when={item.selection}>
{(sel) => (
@@ -1,32 +0,0 @@
import { describe, expect, test } from "bun:test"
import { serverAttachmentFile } from "./server-attachment"
describe("serverAttachmentFile", () => {
test("creates a file from server text content", async () => {
const file = serverAttachmentFile("docs/readme.txt", {
uri: "file:///docs/readme.txt",
name: "readme.txt",
content: "hello",
encoding: "utf8",
mime: "text/plain",
})
expect(file.name).toBe("readme.txt")
expect(file.type).toBe("text/plain")
expect(await file.text()).toBe("hello")
})
test("creates a file from server base64 content", async () => {
const file = serverAttachmentFile("images/pixel.png", {
uri: "file:///images/pixel.png",
name: "pixel.png",
content: "aGVsbG8=",
encoding: "base64",
mime: "image/png",
})
expect(file.name).toBe("pixel.png")
expect(file.type).toBe("image/png")
expect(await file.text()).toBe("hello")
})
})
@@ -1,8 +0,0 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { FileSystemContent } from "@opencode-ai/sdk/v2"
export function serverAttachmentFile(path: string, data: FileSystemContent) {
const content =
data.encoding === "utf8" ? data.content : Uint8Array.from(atob(data.content), (char) => char.charCodeAt(0))
return new File([content], getFilename(path), { type: data.mime })
}
@@ -26,6 +26,22 @@ let selected = "/repo/worktree-a"
let variant: string | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const prompt = {
ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }),
current: () => promptValue,
cursor: () => 0,
dirty: () => true,
reset: () => undefined,
set: () => undefined,
context: {
add: () => undefined,
remove: () => undefined,
removeComment: () => undefined,
updateComment: () => undefined,
replaceComments: () => undefined,
items: () => [],
},
}
const clientFor = (directory: string) => {
createdClients.push(directory)
@@ -61,6 +77,8 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => params,
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
mock.module("@opencode-ai/sdk/v2/client", () => ({
@@ -71,6 +89,7 @@ beforeAll(async () => {
}))
mock.module("@opencode-ai/ui/toast", () => ({
Toast: { Region: () => null },
showToast: () => 0,
}))
@@ -103,19 +122,20 @@ beforeAll(async () => {
}),
}))
mock.module("@/context/prompt", () => ({
usePrompt: () => ({
current: () => promptValue,
reset: () => undefined,
set: () => undefined,
context: {
add: () => undefined,
remove: () => undefined,
items: () => [],
},
mock.module("@/context/server", () => ({
useServer: () => ({ key: "server-key" }),
}))
mock.module("@/context/tabs", () => ({
useTabs: () => ({
promoteDraft: () => undefined,
}),
}))
mock.module("@/context/prompt", () => ({
usePrompt: () => prompt,
}))
mock.module("@/context/layout", () => ({
useLayout: () => ({
handoff: {
@@ -135,12 +155,12 @@ beforeAll(async () => {
return clientFor(opts.directory)
},
}
return sdk
return () => sdk
},
}))
mock.module("@/context/sync", () => ({
useSync: () => ({
useSync: () => () => ({
data: { command: [] },
session: {
optimistic: {
@@ -164,7 +184,7 @@ beforeAll(async () => {
}))
mock.module("@/context/server-sync", () => ({
useServerSync: () => ({
useServerSync: () => () => ({
child: (directory: string) => {
syncedDirectories.push(directory)
storedSessions[directory] ??= []
@@ -220,6 +240,7 @@ beforeEach(() => {
describe("prompt submit worktree selection", () => {
test("reads the latest worktree accessor value per submit", async () => {
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
@@ -257,6 +278,7 @@ describe("prompt submit worktree selection", () => {
test("applies auto-accept to newly created sessions", async () => {
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
@@ -287,6 +309,7 @@ describe("prompt submit worktree selection", () => {
variant = "high"
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
@@ -318,6 +341,7 @@ describe("prompt submit worktree selection", () => {
test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
@@ -2,17 +2,19 @@ import type { Message, Session } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams } from "@solidjs/router"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { batch, type Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useServerSync } from "@/context/server-sync"
import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useSDK, type DirectorySDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { buildRequestParts } from "./build-request-parts"
@@ -38,9 +40,9 @@ export type FollowupDraft = {
}
type FollowupSendInput = {
client: ReturnType<typeof useSDK>["client"]
serverSync: ReturnType<typeof useServerSync>
sync: ReturnType<typeof useSync>
client: DirectorySDK["client"]
serverSync: ServerSync
sync: DirectorySync
draft: FollowupDraft
messageID?: string
optimisticBusy?: boolean
@@ -172,6 +174,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}
type PromptSubmitInput = {
prompt: ReturnType<typeof usePrompt>
info: Accessor<{ id: string } | undefined>
imageAttachments: Accessor<ImageAttachmentPart[]>
commentCount: Accessor<number>
@@ -209,11 +212,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const serverSync = useServerSync()
const local = useLocal()
const permission = usePermission()
const prompt = usePrompt()
const prompt = input.prompt
const layout = useLayout()
const language = useLanguage()
const params = useParams()
const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID)
const [search] = useSearchParams<{ draftId?: string }>()
const server = useServer()
const tabs = useTabs()
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
@@ -228,8 +234,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
serverSync.todo.set(sessionID, [])
const [, setStore] = serverSync.child(sdk.directory)
serverSync().todo.set(sessionID, [])
const [, setStore] = serverSync().child(sdk().directory)
setStore("todo", sessionID, [])
input.onAbort?.()
@@ -242,8 +248,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
pending.delete(key)
return Promise.resolve()
}
return sdk.client.session
.abort({
return sdk()
.client.session.abort({
sessionID,
})
.catch(() => {})
@@ -276,7 +282,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
const seed = (dir: string, info: Session) => {
const [, setStore] = serverSync.child(dir)
const [, setStore] = serverSync().child(dir)
setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
@@ -316,13 +322,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.addToHistory(currentPrompt, mode)
input.resetHistoryNavigation()
const projectDirectory = sdk.directory
const projectDirectory = sdk().directory
const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory
let client = sdk.client
let client = sdk().client
if (isNewSession) {
if (worktreeSelection === "create") {
@@ -344,7 +350,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
})
return
}
WorktreeState.pending(sdk.scope, createdWorktree.directory)
WorktreeState.pending(sdk().scope, createdWorktree.directory)
sessionDirectory = createdWorktree.directory
}
@@ -353,11 +359,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
if (sessionDirectory !== projectDirectory) {
client = sdk.createClient({
client = sdk().createClient({
directory: sessionDirectory,
throwOnError: true,
})
serverSync.child(sessionDirectory)
serverSync().child(sessionDirectory)
}
input.onNewSessionWorktreeReset?.()
@@ -381,7 +387,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
const draftID = search.draftId
if (draftID)
tabs.promoteDraft(draftID, {
server: server.key,
dirBase64: base64Encode(sessionDirectory),
sessionId: session.id,
})
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
}
if (!session) {
@@ -458,7 +471,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const customCommand = sync.data.command.find((c) => c.name === commandName)
const customCommand = sync().data.command.find((c) => c.name === commandName)
if (customCommand) {
clearInput()
client.session
@@ -492,7 +505,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const messageID = Identifier.ascending("message")
const removeOptimisticMessage = () => {
sync.session.optimistic.remove({
sync().session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
@@ -503,17 +516,17 @@ export function createPromptSubmit(input: PromptSubmitInput) {
clearInput()
const waitForWorktree = async () => {
const worktree = WorktreeState.get(sdk.scope, sessionDirectory)
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
if (!worktree || worktree.status !== "pending") return true
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "busy" })
sync().set("session_status", session.id, { type: "busy" })
}
const controller = new AbortController()
const cleanup = () => {
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })
sync().set("session_status", session.id, { type: "idle" })
}
removeOptimisticMessage()
restoreCommentItems(commentItems)
@@ -547,12 +560,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}, timeoutMs)
})
const result = await Promise.race([WorktreeState.wait(sdk.scope, sessionDirectory), abortWait, timeout]).finally(
() => {
if (timer.id === undefined) return
clearTimeout(timer.id)
},
)
const result = await Promise.race([
WorktreeState.wait(sdk().scope, sessionDirectory),
abortWait,
timeout,
]).finally(() => {
if (timer.id === undefined) return
clearTimeout(timer.id)
})
pending.delete(pendingKey(session.id))
if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message)
@@ -561,8 +576,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
void sendFollowupDraft({
client,
sync,
serverSync,
sync: sync(),
serverSync: serverSync(),
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
@@ -570,7 +585,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}).catch((err) => {
pending.delete(pendingKey(session.id))
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })
sync().set("session_status", session.id, { type: "idle" })
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
@@ -42,7 +42,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const usd = createMemo(
() =>
@@ -96,13 +96,13 @@ export function SessionContextTab() {
const providers = useProviders()
const { params, view } = useSessionLayout()
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
const messages = createMemo(
() => {
const id = params.id
if (!id) return emptyMessages
return (sync.data.message[id] ?? []) as Message[]
return (sync().data.message[id] ?? []) as Message[]
},
emptyMessages,
{ equals: same },
@@ -180,7 +180,7 @@ export function SessionContextTab() {
if (!c?.input) return []
return estimateSessionContextBreakdown({
messages: messages(),
parts: sync.data.part as Record<string, Part[] | undefined>,
parts: sync().data.part as Record<string, Part[] | undefined>,
input: c.input,
systemPrompt: systemPrompt(),
})
@@ -221,7 +221,7 @@ export function SessionContextTab() {
let scroll: HTMLDivElement | undefined
let frame: number | undefined
let pending: { x: number; y: number } | undefined
const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[]
const restoreScroll = () => {
const el = scroll
@@ -155,11 +155,9 @@ export function SessionHeader() {
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns())
const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true))
const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true))
const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true))
const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true))
const isV2 = settings.general.newLayoutDesigns
const search = settings.visibility.search
const status = settings.visibility.status
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
@@ -231,7 +229,7 @@ export function SessionHeader() {
)
const opening = createMemo(() => openRequest.app !== undefined)
const tint = createMemo(() =>
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
)
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
statusVisible: status(),
@@ -322,7 +320,7 @@ export function SessionHeader() {
{(mount) => (
<Portal mount={mount()}>
<Show
when={isDesktopV2}
when={isV2}
fallback={
<div class="flex items-center gap-2">
<Show when={projectDirectory()}>
@@ -444,23 +442,21 @@ export function SessionHeader() {
<StatusPopover />
</Tooltip>
</Show>
<Show when={term()}>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
</Show>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
@@ -479,32 +475,30 @@ export function SessionHeader() {
</Button>
</TooltipKeybind>
<Show when={tree()}>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</Show>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</div>
</div>
</div>
@@ -20,24 +20,24 @@ export function NewSessionView(props: NewSessionViewProps) {
const sdk = useSDK()
const language = useLanguage()
const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
const sandboxes = createMemo(() => sync().project?.sandboxes ?? [])
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
const current = createMemo(() => {
const selection = props.worktree
if (options().includes(selection)) return selection
return MAIN_WORKTREE
})
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
const isWorktree = createMemo(() => {
const project = sync.project
const project = sync().project
if (!project) return false
return sdk.directory !== project.worktree
return sdk().directory !== project.worktree
})
const label = (value: string) => {
if (value === MAIN_WORKTREE) {
if (isWorktree()) return language.t("session.new.worktree.main")
const branch = sync.data.vcs?.branch
const branch = sync().data.vcs?.branch
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
return language.t("session.new.worktree.main")
}
@@ -69,7 +69,7 @@ export function NewSessionView(props: NewSessionViewProps) {
{label(current())}
</div>
</div>
<Show when={sync.project}>
<Show when={sync().project}>
{(project) => (
<div class="flex items-start justify-center gap-3 min-h-5">
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
@@ -127,8 +127,8 @@ export const SettingsGeneral: Component = () => {
const [shells] = createResource(
() =>
serverSdk.client.pty
.shells()
serverSdk()
.client.pty.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
@@ -151,11 +151,11 @@ export const SettingsGeneral: Component = () => {
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = serverSync.data.config.shell
const current = serverSync().data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
@@ -290,7 +290,7 @@ export const SettingsGeneral: Component = () => {
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync.updateConfig({ shell: option.value })
serverSync().updateConfig({ shell: option.value })
}}
variant="secondary"
size="small"
@@ -83,7 +83,7 @@ const SettingsProvidersContent: Component = () => {
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = serverSync.data.config.provider?.[providerID]
const provider = serverSync().data.config.provider?.[providerID]
if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false
@@ -91,11 +91,11 @@ const SettingsProvidersContent: Component = () => {
}
const disableProvider = async (providerID: string, name: string) => {
const before = serverSync.data.config.disabled_providers ?? []
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync.set("config", "disabled_providers", next)
serverSync().set("config", "disabled_providers", next)
await serverSync
await serverSync()
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
@@ -106,7 +106,7 @@ const SettingsProvidersContent: Component = () => {
})
})
.catch((err: unknown) => {
serverSync.set("config", "disabled_providers", before)
serverSync().set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
@@ -114,14 +114,16 @@ const SettingsProvidersContent: Component = () => {
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSDK.client.auth.remove({ providerID }).catch(() => undefined)
await serverSDK()
.client.auth.remove({ providerID })
.catch(() => undefined)
await disableProvider(providerID, name)
return
}
await serverSDK.client.auth
.remove({ providerID })
await serverSDK()
.client.auth.remove({ providerID })
.then(async () => {
await serverSDK.client.global.dispose()
await serverSDK().client.global.dispose()
showToast({
variant: "success",
icon: "circle-check",
@@ -30,7 +30,7 @@ function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnecti
return (
<QueryClientProvider client={serverCtx().queryClient}>
<ServerSDKProvider server={props.server}>
<ServerSDKProvider server={() => props.server}>
<ServerSyncProvider>
<ModelsProvider>{props.children}</ModelsProvider>
</ServerSyncProvider>
@@ -1,16 +1,14 @@
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { usePlatform } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action"
@@ -94,7 +92,6 @@ export const SettingsGeneralV2: Component = () => {
const updater = useUpdaterAction()
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir))
const accepting = createMemo(() => {
const value = dir()
@@ -129,19 +126,13 @@ export const SettingsGeneralV2: Component = () => {
const [shells] = createResource(
() =>
serverSdk.client.pty
.shells()
serverSdk()
.client.pty.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
() => (linux() && platform.getDisplayBackend ? true : false),
() => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
{ initialValue: null as DisplayBackend | null },
)
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
@@ -153,11 +144,11 @@ export const SettingsGeneralV2: Component = () => {
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = serverSync.data.config.shell
const current = serverSync().data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
@@ -186,14 +177,6 @@ export const SettingsGeneralV2: Component = () => {
return options
})
const onDisplayBackendChange = (checked: boolean) => {
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
if (!update) return
void update.finally(() => {
void refetchDisplayBackend()
})
}
const onPinchZoomChange = (checked: boolean) => {
setPinchZoom(checked)
const update = platform.setPinchZoomEnabled?.(checked)
@@ -292,7 +275,7 @@ export const SettingsGeneralV2: Component = () => {
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync.updateConfig({ shell: option.value })
serverSync().updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
@@ -383,18 +366,6 @@ export const SettingsGeneralV2: Component = () => {
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showNavigation.title")}
description={language.t("settings.general.row.showNavigation.description")}
>
<div data-action="settings-show-navigation">
<Switch
checked={settings.general.showNavigation()}
onChange={(checked) => settings.general.setShowNavigation(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}
@@ -407,18 +378,6 @@ export const SettingsGeneralV2: Component = () => {
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showTerminal.title")}
description={language.t("settings.general.row.showTerminal.description")}
>
<div data-action="settings-show-terminal">
<Switch
checked={settings.general.showTerminal()}
onChange={(checked) => settings.general.setShowTerminal(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
@@ -709,6 +668,7 @@ export const SettingsGeneralV2: Component = () => {
</div>
)
// We can probably remove this, right?
const DisplaySection = () => (
<Show when={desktop()}>
<div class="settings-v2-section">
@@ -723,26 +683,6 @@ export const SettingsGeneralV2: Component = () => {
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRowV2>
<Show when={linux()}>
<SettingsRowV2
title={
<div class="flex items-center gap-2">
<span>{language.t("settings.general.row.wayland.title")}</span>
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
<span class="text-text-weak">
<Icon name="help" size="small" />
</span>
</Tooltip>
</div>
}
description={language.t("settings.general.row.wayland.description")}
>
<div data-action="settings-wayland">
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
</div>
</SettingsRowV2>
</Show>
</SettingsListV2>
</div>
</Show>
@@ -763,13 +703,13 @@ export const SettingsGeneralV2: Component = () => {
<SoundsSection />
<UpdatesSection />
<Show when={desktop()}>
<UpdatesSection />
</Show>
<DisplaySection />
<Show when={desktop()}>
<AdvancedSection />
</Show>
<AdvancedSection />
</div>
</>
)
@@ -77,7 +77,7 @@ export const SettingsProvidersV2: Component = () => {
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = serverSync.data.config.provider?.[providerID]
const provider = serverSync().data.config.provider?.[providerID]
if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false
@@ -85,11 +85,11 @@ export const SettingsProvidersV2: Component = () => {
}
const disableProvider = async (providerID: string, name: string) => {
const before = serverSync.data.config.disabled_providers ?? []
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync.set("config", "disabled_providers", next)
serverSync().set("config", "disabled_providers", next)
await serverSync
await serverSync()
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
@@ -100,7 +100,7 @@ export const SettingsProvidersV2: Component = () => {
})
})
.catch((err: unknown) => {
serverSync.set("config", "disabled_providers", before)
serverSync().set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
@@ -108,14 +108,16 @@ export const SettingsProvidersV2: Component = () => {
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSdk.client.auth.remove({ providerID }).catch(() => undefined)
await serverSdk()
.client.auth.remove({ providerID })
.catch(() => undefined)
await disableProvider(providerID, name)
return
}
await serverSdk.client.auth
.remove({ providerID })
await serverSdk()
.client.auth.remove({ providerID })
.then(async () => {
await serverSdk.client.global.dispose()
await serverSdk().client.global.dispose()
showToast({
variant: "success",
icon: "circle-check",
@@ -279,13 +279,13 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
const toggleMcp = useMcpToggle()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync.data.lsp ?? [])
const lspItems = createMemo(() => sync().data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const plugins = createMemo(() =>
(sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
(sync().data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
)
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
@@ -18,9 +18,9 @@ export function StatusPopover() {
const global = useGlobal()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync.data.mcp_ready)
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync.data.mcp ?? {})
const mcp = Object.values(sync().data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
@@ -86,9 +86,9 @@ function DirectoryStatusPopover() {
const sync = useSync()
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready)
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync.data.mcp ?? {})
const mcp = Object.values(sync().data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
+3 -3
View File
@@ -161,9 +161,9 @@ export const Terminal = (props: TerminalProps) => {
const theme = useTheme()
const language = useLanguage()
const server = useServer()
const directory = sdk.directory
const client = sdk.client
const url = sdk.url
const directory = sdk().directory
const client = sdk().client
const url = sdk().url
const auth = server.current?.http
const username = auth?.username ?? "opencode"
const password = auth?.password ?? ""
+46
View File
@@ -0,0 +1,46 @@
@keyframes titlebar-tab-fade-left {
from {
visibility: hidden;
}
to {
visibility: visible;
}
}
@keyframes titlebar-tab-fade-right {
from {
visibility: visible;
}
to {
visibility: hidden;
}
}
/* The narrow ranges preserve the binary thresholds of scrollLeft > 0 and
remaining scroll distance > 1px. Hidden defaults make an inactive timeline
(no overflow) a no-op. Unsupported browsers also degrade to no fades. */
[data-slot="titlebar-tabs"] {
timeline-scope: --titlebar-tabs-scroll;
[data-slot^="titlebar-tabs-fade-"] {
visibility: hidden;
}
}
@supports (animation-timeline: --titlebar-tabs-scroll) and (timeline-scope: --titlebar-tabs-scroll) {
[data-slot="titlebar-tabs-scroll"] {
scroll-timeline: --titlebar-tabs-scroll x;
}
[data-slot="titlebar-tabs-fade-left"] {
animation: titlebar-tab-fade-left linear both;
animation-timeline: --titlebar-tabs-scroll;
animation-range: 0 0.1px;
}
[data-slot="titlebar-tabs-fade-right"] {
animation: titlebar-tab-fade-right linear both;
animation-timeline: --titlebar-tabs-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
}
}
+197 -90
View File
@@ -7,12 +7,11 @@ import {
Match,
onMount,
Show,
startTransition,
Switch,
untrack,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
@@ -20,6 +19,8 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
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 { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout"
import { usePlatform } from "@/context/platform"
@@ -34,11 +35,13 @@ import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
import { useGlobal } from "@/context/global"
import { decode64 } from "@/utils/base64"
import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs, type Tab } from "@/context/tabs"
import "./titlebar.css"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@@ -251,7 +254,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
{(_) => {
const serverSync = useServerSync()
const navigate = useNavigate()
const homeMatch = useMatch(() => "/")
const layout = useLayout()
const newSessionHref = () => {
@@ -266,21 +268,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const tabs = useTabs()
const tabsStore = tabs.store
const tabsStoreActions = tabs
const navigateTab = (tab: Tab) => {
const href = tabHref(tab)
if (tab.server === server.key) {
navigate(href)
return
}
void startTransition(() => {
server.setActive(tab.server)
navigate(href)
})
}
const matchRoute = (route: LayoutRoute) => {
if (route.type === "home") return
if (route.type === "dir-new-sesssion") {
if (route.type === "draft") {
return tabsStore.find((item) => item.type === "draft" && item.draftID === route.draftID)
}
if (route.type === "session") {
const main = tabsStore.find(
@@ -288,7 +280,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
)
if (main) return main
const sync = serverSync.createDirSyncContext(route.dir)
const sync = serverSync().createDirSyncContext(route.dir)
const session = sync.session.get(route.sessionId)
if (session?.parentID) {
const parentID = session.parentID
@@ -306,10 +298,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const route = layout.route()
if (!tabs.ready()) return
const tab = currentTab()
if (tab) return
if (tab) {
tabs.remember(tab)
return
}
if (route.type === "session") {
const sync = serverSync.createDirSyncContext(route.dir)
const sync = serverSync().createDirSyncContext(route.dir)
const session = sync.session.get(route.sessionId)
if (!session) return
const sessionId = session.parentID ?? session.id
@@ -329,6 +324,18 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
})
const openNewTab = () => navigate(newSessionHref())
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
command.register("titlebar-home", () => [
{
id: "home.toggle",
title: language.t("home.title"),
category: language.t("command.category.view"),
keybind: "mod+b",
hidden: true,
onSelect: toggleHome,
},
])
command.register("tabs", () => {
const current = currentTab()
@@ -366,7 +373,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
if (index === -1) index = tabsStore.length - 1
const next = tabsStore[index]
if (next) navigateTab(next)
if (next) tabs.select(next)
},
},
{
@@ -383,7 +390,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
if (index === tabsStore.length) index = 0
const next = tabsStore[index]
if (next) navigateTab(next)
if (next) tabs.select(next)
},
},
...Array.from({ length: 9 }, (_, i) => {
@@ -398,7 +405,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
hidden: true,
onSelect: () => {
const tab = tabsStore[index]
if (tab) navigateTab(tab)
if (tab) tabs.select(tab)
},
}
}),
@@ -424,82 +431,130 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} variant="v2" />
</Show>
<IconButtonV2
variant="ghost-muted"
size="large"
as="a"
href="/"
class="!w-9 shrink-0"
icon={<IconV2 name="grid-plus" />}
state={!!homeMatch() ? "pressed" : undefined}
/>
<div
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
ref={tabScrollRef}
<TooltipV2
placement="bottom"
value={
<>
{language.t("home.title")}
<KeybindV2 keys={command.keybindParts("home.toggle")} variant="neutral" />
</>
}
class="shrink-0"
>
<div class="flex min-w-0 flex-row items-center gap-1.5">
<For each={tabsStore}>
{(tab, i) => {
let ref!: HTMLDivElement
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
icon={<IconV2 name="grid-plus" />}
state={layout.route().type === "home" ? "pressed" : undefined}
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
/>
</TooltipV2>
onMount(() => {
refreshTabsAreOverflowing()
})
<div data-slot="titlebar-tabs" class="relative min-w-0">
<div
data-slot="titlebar-tabs-scroll"
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
ref={(el) => {
tabScrollRef = el
createResizeObserver(el, refreshTabsAreOverflowing)
}}
>
<div
class="flex min-w-0 flex-row items-center gap-1.5"
ref={(el) => createResizeObserver(el, refreshTabsAreOverflowing)}
>
<For each={tabsStore}>
{(tab, i) => {
let ref!: HTMLDivElement
if (tab.type !== "session") return null
return (
<>
{i() !== 0 && (
const divider = () =>
i() !== 0 && (
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
)}
<TabNavItem
ref={ref}
href={tabHref(tab)}
server={tab.server}
directory={decode64(tab.dirBase64)!}
sessionId={tab.sessionId}
onNavigate={() => {
navigateTab(tab)
)
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
/>
</>
)
}}
</For>
<Show when={creating() && params.dir}>
{(_) => {
let ref!: HTMLDivElement
if (tab.type === "draft") {
return (
<>
{divider()}
<DraftTabItem
ref={ref}
href={tabHref(tab)}
title={language.t("command.session.new")}
active={currentTab() === tab}
onNavigate={() => {
tabs.select(tab)
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
/>
</>
)
}
onMount(() => {
ref.scrollIntoView({ behavior: "instant" })
})
return (
<>
{divider()}
<TabNavItem
ref={ref}
href={tabHref(tab)}
server={tab.server}
directory={decode64(tab.dirBase64)!}
sessionId={tab.sessionId}
onNavigate={() => {
tabs.select(tab)
return (
<>
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
<NewSessionTabItem
ref={ref}
href={`/${params.dir}/session`}
title={language.t("command.session.new")}
onClose={() => {
const tab = tabsStore.at(-1)
if (tab) navigateTab(tab)
else navigate("/")
}}
/>
</>
)
}}
</Show>
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
/>
</>
)
}}
</For>
<Show when={creating() && params.dir}>
{(_) => {
let ref!: HTMLDivElement
onMount(() => {
ref.scrollIntoView({ behavior: "instant" })
})
return (
<>
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
<NewSessionTabItem
ref={ref}
href={`/${params.dir}/session`}
title={language.t("command.session.new")}
onClose={() => {
const tab = tabsStore.at(-1)
if (tab) tabs.select(tab)
else navigate("/")
}}
/>
</>
)
}}
</Show>
</div>
</div>
<div
data-slot="titlebar-tabs-fade-left"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
/>
<div
data-slot="titlebar-tabs-fade-right"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
/>
</div>
<Show when={!(creating() && params.dir)}>
<IconButtonV2
@@ -784,7 +839,6 @@ function TabNavItem(props: {
>
<Show when={session.latest}>
{(session) => {
console.log({ session: session() })
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
return (
@@ -853,6 +907,59 @@ function ProjectTabAvatar(props: {
)
}
function DraftTabItem(props: {
ref?: HTMLDivElement
href: string
title: string
active?: boolean
onNavigate: () => void
onClose: () => void
}) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
return (
<div
ref={props.ref}
data-active={props.active}
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<a
href={props.href}
onClick={(event) => {
event.preventDefault()
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
>
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
<IconV2 name="edit" />
</span>
<span class="truncate leading-5">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
<IconButtonV2
size="small"
variant="ghost-muted"
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
/>
</div>
</div>
)
}
function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: string; onClose: () => void }) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
+57 -50
View File
@@ -170,13 +170,7 @@ export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean
return false
}
export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string {
if (!config || config === "none") return ""
const keybinds = parseKeybind(config)
if (keybinds.length === 0) return ""
const kb = keybinds[0]
function displayKeybindParts(kb: Keybind, t?: (key: KeyLabel) => string) {
const parts: string[] = []
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t))
@@ -184,40 +178,52 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t))
if (kb.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t))
if (kb.key) {
const keys: Record<string, string> = {
arrowup: "↑",
arrowdown: "↓",
arrowleft: "←",
arrowright: "→",
comma: ",",
plus: "+",
}
const named: Record<string, KeyLabel> = {
backspace: "common.key.backspace",
delete: "common.key.delete",
end: "common.key.end",
enter: "common.key.enter",
esc: "common.key.esc",
escape: "common.key.esc",
home: "common.key.home",
insert: "common.key.insert",
pagedown: "common.key.pageDown",
pageup: "common.key.pageUp",
space: "common.key.space",
tab: "common.key.tab",
}
const key = kb.key.toLowerCase()
const displayKey =
keys[key] ??
(named[key]
? keyText(named[key], t)
: key.length === 1
? key.toUpperCase()
: key.charAt(0).toUpperCase() + key.slice(1))
parts.push(displayKey)
}
if (!kb.key) return parts
const keys: Record<string, string> = {
arrowup: "↑",
arrowdown: "↓",
arrowleft: "←",
arrowright: "→",
comma: ",",
plus: "+",
}
const named: Record<string, KeyLabel> = {
backspace: "common.key.backspace",
delete: "common.key.delete",
end: "common.key.end",
enter: "common.key.enter",
esc: "common.key.esc",
escape: "common.key.esc",
home: "common.key.home",
insert: "common.key.insert",
pagedown: "common.key.pageDown",
pageup: "common.key.pageUp",
space: "common.key.space",
tab: "common.key.tab",
}
const key = kb.key.toLowerCase()
const displayKey =
keys[key] ??
(named[key]
? keyText(named[key], t)
: key.length === 1
? key.toUpperCase()
: key.charAt(0).toUpperCase() + key.slice(1))
parts.push(displayKey)
return parts
}
export function formatKeybindParts(config: string, t?: (key: KeyLabel) => string): string[] {
if (!config || config === "none") return []
const keybind = parseKeybind(config)[0]
return keybind ? displayKeybindParts(keybind, t) : []
}
export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string {
const parts = formatKeybindParts(config, t)
if (parts.length === 0) return ""
return IS_MAC ? parts.join("") : parts.join("+")
}
@@ -402,25 +408,26 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
})
}
const keybindConfig = (id: string) => {
if (id === PALETTE_ID) return settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
const base = actionId(id)
return options().find((x) => actionId(x.id) === base)?.keybind ?? bind(base, catalog[base]?.keybind)
}
return {
register,
trigger(id: string, source?: CommandSource) {
run(id, source)
},
keybind(id: string) {
if (id === PALETTE_ID) {
return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND, language.t)
}
const base = actionId(id)
const option = options().find((x) => actionId(x.id) === base)
if (option?.keybind) return formatKeybind(option.keybind, language.t)
const meta = catalog[base]
const config = bind(base, meta?.keybind)
const config = keybindConfig(id)
if (!config) return ""
return formatKeybind(config, language.t)
},
keybindParts(id: string) {
const config = keybindConfig(id)
return config ? formatKeybindParts(config, language.t) : []
},
show: showPalette,
keybinds(enabled: boolean) {
setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
@@ -8,6 +8,8 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
+1 -1
View File
@@ -208,7 +208,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
const decoded = decodeSessionKey(key)
return createRoot((dispose) => ({
value: createCommentSession(
serverSDK.scope,
serverSDK().scope,
decoded.dir,
decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
),
+2 -2
View File
@@ -11,7 +11,7 @@ import {
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
import { createServerSdkContext, useServerSDK } from "./server-sdk"
import { type createServerSdkContext } from "./server-sdk"
import { type createServerSyncContextInner } from "./server-sync"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -174,7 +174,7 @@ function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: Opti
export const createDirSyncContext = (
directory: string,
serverSync: ReturnType<typeof createServerSyncContextInner>,
serverSDK: ReturnType<typeof createServerSdkContext> = useServerSDK(),
serverSDK: ReturnType<typeof createServerSdkContext>,
) => {
const client = serverSDK.createClient({ directory, throwOnError: true })
+16 -11
View File
@@ -62,10 +62,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const language = useLanguage()
const layout = useLayout()
const scope = createMemo(() => sdk.directory)
const scope = createMemo(() => sdk().directory)
const path = createPathHelpers(scope)
const tabs = layout.tabs(() =>
SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(params.dir, params.id)),
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)),
)
const inflight = new Map<string, Promise<void>>()
@@ -78,7 +78,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const tree = createFileTreeStore({
scope,
normalizeDir: path.normalizeDir,
list: (dir) => sdk.client.file.list({ path: dir }).then((x) => x.data ?? []),
list: (dir) =>
sdk()
.client.file.list({ path: dir })
.then((x) => x.data ?? []),
onError: (message) => {
showToast({
variant: "error",
@@ -112,7 +115,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
})
})
const viewCache = createFileViewCache(serverSDK.scope)
const viewCache = createFileViewCache(serverSDK().scope)
const view = createMemo(() => viewCache.load(scope(), params.id))
const ensure = (file: string) => {
@@ -176,8 +179,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setLoading(file)
const promise = sdk.client.file
.read({ path: file })
const promise = sdk()
.client.file.read({ path: file })
.then((x) => {
if (scope() !== directory) return
const content = x.data
@@ -200,12 +203,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}
const search = (query: string, dirs: "true" | "false") =>
sdk.client.find.files({ query, dirs }).then(
(x) => (x.data ?? []).map(path.normalize),
() => [],
)
sdk()
.client.find.files({ query, dirs })
.then(
(x) => (x.data ?? []).map(path.normalize),
() => [],
)
const stop = sdk.event.listen((e) => {
const stop = sdk().event.listen((e) => {
invalidateFromWatcher(e.details, {
normalize: path.normalize,
hasFile: (file) => Boolean(store.file[file]),
@@ -134,6 +134,26 @@ describe("applyGlobalEvent", () => {
})
describe("applyDirectoryEvent", () => {
test("initializes text delta accumulation from the current part text", () => {
const part = { ...textPart("part", "session", "message"), text: "existing" }
const [store, setStore] = createStore(baseState({ part: { message: [part] } }))
applyDirectoryEvent({
event: {
type: "message.part.delta",
properties: { messageID: "message", partID: "part", field: "text", delta: " appended" },
},
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.part_text_accum_delta.part).toBe("existing appended")
expect((store.part.message?.[0] as { text: string }).text).toBe("existing appended")
})
test("preserves a Home-specific retained session limit", () => {
const [store, setStore] = createStore(
baseState({
@@ -282,7 +282,13 @@ export function applyDirectoryEvent(input: {
if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id)
if (!result.found) break
input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta)
const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field]
input.setStore(
"part_text_accum_delta",
props.partID,
(existing) => (existing ?? (typeof current === "string" ? current : "")) + props.delta,
)
input.setStore(
"part",
props.messageID,
+21 -14
View File
@@ -77,6 +77,7 @@ export type ReviewDiffStyle = "unified" | "split"
export type LayoutRoute =
| { type: "home" }
| { type: "draft"; draftID: string; server?: ServerConnection.Key }
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
| { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key }
@@ -120,10 +121,16 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
}
}
const currentRoute = (pathname: string): LayoutRoute => {
const currentRoute = (pathname: string, search: string): LayoutRoute => {
const parts = pathname.split("/").filter(Boolean)
if (parts.length === 0) return { type: "home" }
if (parts[0] === "new-session") {
const draftID = new URLSearchParams(search).get("draftId")
if (!draftID) return { type: "home" }
return { type: "draft", draftID }
}
const dirBase64 = parts[0]
const dir = decode64(dirBase64)
if (!dir) return { type: "home" }
@@ -145,7 +152,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const platform = usePlatform()
const location = useLocation()
const route = createMemo(() => {
const value = currentRoute(location.pathname)
const value = currentRoute(location.pathname, location.search)
if (value.type === "home") return value
return { ...value, server: server.key }
})
@@ -239,7 +246,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
}
const target = Persist.serverGlobal(serverSdk.scope, "layout", ["layout.v6"])
const target = Persist.serverGlobal(serverSdk().scope, "layout", ["layout.v6"])
const [store, setStore, _, ready] = persisted(
{ ...target, migrate },
createStore({
@@ -402,11 +409,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = serverSync.child(project.worktree, { bootstrap: false })
const [childStore] = serverSync().child(project.worktree, { bootstrap: false })
const projectID = childStore.project
const metadata = projectID
? serverSync.data.project.find((x) => x.id === projectID)
: serverSync.data.project.find((x) => x.worktree === project.worktree)
? serverSync().data.project.find((x) => x.id === projectID)
: serverSync().data.project.find((x) => x.worktree === project.worktree)
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// Without this, different subdirectories of the same git repo would share the same
@@ -420,7 +427,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const roots = createMemo(() => {
const map = new Map<string, string>()
for (const project of serverSync.data.project) {
for (const project of serverSync().data.project) {
const sandboxes = project.sandboxes ?? []
for (const sandbox of sandboxes) {
map.set(sandbox, project.worktree)
@@ -486,12 +493,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
createEffect(() => {
const projects = enriched()
if (projects.length === 0) return
if (!serverSync.ready) return
if (!serverSync().ready) return
for (const project of projects) {
if (!project.id) continue
if (project.id === "global") continue
serverSync.project.icon(project.worktree, project.icon?.override)
serverSync().project.icon(project.worktree, project.icon?.override)
}
})
@@ -525,12 +532,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
colorRequested.set(worktree, color)
if (project.id === "global") {
serverSync.project.meta(worktree, { icon: { color } })
serverSync().project.meta(worktree, { icon: { color } })
continue
}
void serverSdk.client.project
.update({ projectID: project.id, directory: worktree, icon: { color } })
void serverSdk()
.client.project.update({ projectID: project.id, directory: worktree, icon: { color } })
.catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
})
@@ -547,7 +554,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
sessionTimer = undefined
void Promise.all(
server.projects.list().map((project) => {
return serverSync.project.loadSessions(project.worktree)
return serverSync().project.loadSessions(project.worktree)
}),
)
}, 0)
@@ -577,7 +584,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
open(directory: string) {
const root = rootFor(directory)
if (server.projects.list().find((x) => x.worktree === root)) return
void serverSync.project.loadSessions(root)
void serverSync().project.loadSessions(root)
server.projects.open(root)
},
close(directory: string) {
+11 -10
View File
@@ -64,12 +64,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const models = useModels()
const id = createMemo(() => params.id || undefined)
const list = createMemo(() => sync.data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved] = persisted(
{
...Persist.serverWorkspace(serverSDK.scope, sdk.directory, "model-selection", ["model-selection.v1"]),
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
migrate,
},
createStore<Saved>({
@@ -124,14 +124,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const scope = createMemo<State | undefined>(() => {
const session = id()
if (!session) return store.draft
return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk.directory, session))
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
})
createEffect(() => {
const session = id()
if (!session) return
const key = handoffKey(serverSDK.scope, sdk.directory, session)
const key = handoffKey(serverSDK().scope, sdk().directory, session)
const next = handoff.get(key)
if (!next) return
if (saved.session[session] !== undefined) {
@@ -144,8 +144,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const configuredModel = () => {
if (!sync.data.config.model) return
const [providerID, modelID] = sync.data.config.model.split("/")
const configured = sync().data.config.model
if (!configured) return
const [providerID, modelID] = configured.split("/")
const model = { providerID, modelID }
if (validModel(model)) return model
}
@@ -363,7 +364,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const result = {
slug: createMemo(() => base64Encode(sdk.directory)),
slug: createMemo(() => base64Encode(sdk().directory)),
model,
agent,
session: {
@@ -374,13 +375,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const next = clone(snapshot())
if (!next) return
if (dir === sdk.directory) {
if (dir === sdk().directory) {
setSaved("session", session, next)
setStore("draft", undefined)
return
}
handoff.set(handoffKey(serverSDK.scope, dir, session), next)
handoff.set(handoffKey(serverSDK().scope, dir, session), next)
setStore("draft", undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
@@ -388,7 +389,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!session) return
if (msg.sessionID !== session) return
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(serverSDK.scope, sdk.directory, session))) return
if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return
setSaved("session", session, {
agent: msg.agent,
+1 -1
View File
@@ -8,7 +8,7 @@ export function useMcpToggle() {
const language = useLanguage()
return useMutation(() => ({
mutationFn: sync.mcp.toggle,
mutationFn: sync().mcp.toggle,
onError: (error) =>
showToast({
variant: "error",
+5 -5
View File
@@ -125,7 +125,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const currentSession = createMemo(() => params.id)
const [store, setStore, _, ready] = persisted(
Persist.serverGlobal(serverSDK.scope, "notification", ["notification.v1"]),
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
createStore({
list: [] as Notification[],
}),
@@ -208,11 +208,11 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const [syncStore] = serverSync.child(directory, { bootstrap: false })
const [syncStore] = serverSync().child(directory, { bootstrap: false })
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
if (match.found) return syncStore.session[match.index]
return serverSDK.client.session
.get({ directory, sessionID })
return serverSDK()
.client.session.get({ directory, sessionID })
.then((x) => x.data)
.catch(() => undefined)
}
@@ -286,7 +286,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
})
}
const unsub = serverSDK.event.listen((e) => {
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return
+16 -14
View File
@@ -55,13 +55,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const permissionsEnabled = createMemo(() => {
const directory = decode64(params.dir)
if (!directory) return false
const [store] = serverSync.child(directory)
const [store] = serverSync().child(directory)
return hasPermissionPromptRules(store.config.permission)
})
const [store, setStore, _, ready] = persisted(
{
...Persist.serverGlobal(serverSDK.scope, "permission", ["permission.v3"]),
...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]),
migrate(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value
@@ -87,7 +87,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
if (!ready()) return
const directory = decode64(params.dir)
if (!directory) return
const [childStore] = serverSync.child(directory)
const [childStore] = serverSync().child(directory)
const perm = childStore.config.permission
if (typeof perm === "string" && perm === "allow") {
const key = directoryAcceptKey(directory)
@@ -119,9 +119,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
const respond: PermissionRespondFn = (input) => {
serverSDK.client.permission.respond(input).catch(() => {
responded.delete(input.permissionID)
})
serverSDK()
.client.permission.respond(input)
.catch(() => {
responded.delete(input.permissionID)
})
}
function respondOnce(permission: PermissionRequest, directory?: string) {
@@ -140,7 +142,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
function isAutoAccepting(sessionID: string, directory?: string) {
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
}
@@ -149,7 +151,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, permission, directory)
}
@@ -160,7 +162,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return next
}
const unsubscribe = serverSDK.event.listen((e) => {
const unsubscribe = serverSDK().event.listen((e) => {
const event = e.details
if (event?.type !== "permission.asked") return
@@ -179,8 +181,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}),
)
serverSDK.client.permission
.list({ directory })
serverSDK()
.client.permission.list({ directory })
.then((x) => {
if (!isAutoAcceptingDirectory(directory)) return
for (const perm of x.data ?? []) {
@@ -211,8 +213,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}),
)
serverSDK.client.permission
.list({ directory })
serverSDK()
.client.permission.list({ directory })
.then((x) => {
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
@@ -269,7 +271,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
},
permissionsEnabled,
isPermissionAllowAll(directory: string) {
const [childStore] = serverSync.child(directory)
const [childStore] = serverSync().child(directory)
const perm = childStore.config.permission
return typeof perm === "string" && perm === "allow"
},
+3
View File
@@ -55,6 +55,9 @@ type PlatformBase = {
onFile: (file: File) => Promise<unknown>,
): Promise<void>
/** Resolve the native source path for a desktop File. */
getPathForFile?(file: File): string
/** Open a native save file picker dialog (desktop only) */
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
+34 -15
View File
@@ -33,6 +33,7 @@ export interface ImageAttachmentPart {
type: "image"
id: string
filename: string
sourcePath?: string
mime: string
dataUrl: string
}
@@ -153,6 +154,14 @@ const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession>
type PromptStore = {
prompt: Prompt
cursor?: number
context: {
items: (ContextItem & { key: string })[]
}
}
type Scope = { draftID: string } | { dir: string; id?: string }
function scopeKey(scope: Scope) {
@@ -174,25 +183,26 @@ function promptTarget(serverScope: ServerScope, scope: Scope) {
function createPromptSession(serverScope: ServerScope, scope: Scope) {
const [store, setStore, _, ready] = persisted(
promptTarget(serverScope, scope),
createStore<{
prompt: Prompt
cursor?: number
context: {
items: (ContextItem & { key: string })[]
}
}>({
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
items: [],
},
}),
createStore<PromptStore>(promptStore()),
)
return { ready, ...createPromptStateValue(store, setStore) }
}
function promptStore(): PromptStore {
return {
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
items: [],
},
}
}
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
const actions = createPromptActions(setStore)
return {
ready,
current: () => store.prompt,
cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
@@ -232,6 +242,15 @@ function createPromptSession(serverScope: ServerScope, scope: Scope) {
}
}
export function createPromptState() {
const [store, setStore] = createStore<PromptStore>(promptStore())
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready: () => ready,
...createPromptStateValue(store, setStore),
}
}
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
name: "Prompt",
gate: false,
@@ -272,7 +291,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const entry = createRoot(
(dispose) => ({
value: createPromptSession(serverSDK.scope, scope),
value: createPromptSession(serverSDK().scope, scope),
dispose,
}),
owner,
+10 -4
View File
@@ -1,11 +1,17 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServerSDK } from "./server-sdk"
import { type Accessor, createMemo } from "solid-js"
import { type ServerSDK, useServerSDK } from "./server-sdk"
export type DirectorySDK = ReturnType<ServerSDK["createDirSdkContext"]>
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { directory: string }) => {
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
init: (props: { directory: string | Accessor<string> }) => {
const serverSDK = useServerSDK()
return serverSDK.createDirSdkContext(props.directory)
return createMemo(() => {
const directory = typeof props.directory === "function" ? props.directory() : props.directory
return serverSDK().createDirSdkContext(directory)
})
},
})
+40 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { resumeStreamAfterPageShow } from "./server-sdk"
import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
import type { Event } from "@opencode-ai/sdk/v2/client"
describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => {
@@ -12,3 +13,41 @@ describe("resumeStreamAfterPageShow", () => {
expect(starts).toBe(1)
})
})
describe("coalesceServerEvents", () => {
const delta = (value: string, field = "text") => ({
directory: "/repo",
payload: {
type: "message.part.delta",
properties: { messageID: "msg", partID: "part", field, delta: value },
} as Event,
})
test("merges adjacent deltas for the same field", () => {
const result = coalesceServerEvents([delta("hello "), delta("world")])
expect(result).toHaveLength(1)
expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } })
})
test("preserves event boundaries and distinct fields", () => {
const status = {
directory: "/repo",
payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event,
}
const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")])
expect(result.map((event) => event.payload.type)).toEqual([
"message.part.delta",
"message.part.delta",
"session.status",
"message.part.delta",
])
})
test("drops stale deltas", () => {
const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"]))
expect(result).toEqual([])
})
})
+57 -21
View File
@@ -2,7 +2,7 @@ import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { batch, onCleanup, onMount } from "solid-js"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
@@ -15,13 +15,46 @@ const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
type QueuedServerEvent = { directory: string; payload: Event }
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set<string>) {
const output: QueuedServerEvent[] = []
const deltas = new Map<string, number>()
events.forEach((event) => {
if (stale && event.payload.type === "message.part.delta") {
const props = event.payload.properties
if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return
}
if (event.payload.type !== "message.part.delta") {
deltas.clear()
output.push(event)
return
}
const props = event.payload.properties
const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}`
const index = deltas.get(id)
const existing = index === undefined ? undefined : output[index]
if (!existing || existing.payload.type !== "message.part.delta") {
deltas.set(id, output.length)
output.push({
directory: event.directory,
payload: { ...event.payload, properties: { ...props } },
})
return
}
existing.payload.properties.delta += props.delta
})
return output
}
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
if (!event.persisted) return
start()
}
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope) {
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
const platform = usePlatform()
const abort = new AbortController()
@@ -45,7 +78,7 @@ export function createServerSdkContext(server: ServerConnection.Any, scope: Serv
[key: string]: Event
}>()
type Queued = { directory: string; payload: Event }
type Queued = QueuedServerEvent
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const RECONNECT_DELAY_MS = 250
@@ -57,8 +90,6 @@ export function createServerSdkContext(server: ServerConnection.Any, scope: Serv
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
@@ -83,14 +114,9 @@ export function createServerSdkContext(server: ServerConnection.Any, scope: Serv
staleDeltas.clear()
last = Date.now()
const output = coalesceServerEvents(events, skip)
batch(() => {
for (const event of events) {
if (skip && event.payload.type === "message.part.delta") {
const props = event.payload.properties
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
}
emitter.emit(event.directory, event.payload)
}
output.forEach((event) => emitter.emit(event.directory, event.payload))
})
buffer.length = 0
@@ -261,21 +287,31 @@ export function createServerSdkContext(server: ServerConnection.Any, scope: Serv
}
}
export type ServerSDK = ReturnType<typeof createServerSdkContext>
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
export type ServerSDK = ServerSDKBase & {
createDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
}
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
const sdk = createServerSdkContextBase(server, scope)
return Object.assign(sdk, {
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
})
}
export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
name: "ServerSDK",
init: (props: { server?: ServerConnection.Any }) => {
// Returns an accessor so the resolved server can change reactively (e.g. a
// /new-session draft retargeting its server) without re-instantiating the subtree.
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
const global = useGlobal()
const language = useLanguage()
const server = useServer()
const conn = props.server ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
const ctx = global.createServerCtx(conn)
return Object.assign(ctx.sdk, {
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, ctx.sdk)),
return createMemo<ServerSDK>(() => {
const conn = props.server?.() ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
return global.createServerCtx(conn).sdk
})
},
})
@@ -284,7 +320,7 @@ type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
}
function createDirSdkContext(directory: string, serverSDK: ServerSDK) {
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
const client = serverSDK.createClient({
directory,
throwOnError: true,
+18 -15
View File
@@ -1,11 +1,11 @@
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { batch, getOwner, onCleanup, onMount, untrack } from "solid-js"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error"
import { ServerSDK, useServerSDK } from "./server-sdk"
import { ServerSDK } from "./server-sdk"
import {
bootstrapDirectory,
bootstrapGlobal,
@@ -84,8 +84,7 @@ function makeQueryOptionsApi(
}
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
const serverSDK: ServerSDK = _serverSDK ?? useServerSDK()
export function createServerSyncContextInner(serverSDK: ServerSDK) {
const language = useLanguage()
const owner = getOwner()
if (!owner) throw new Error("ServerSync must be created within owner")
@@ -507,33 +506,37 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
}
}
export function createServerSyncContext(_serverSDK?: ServerSDK) {
const inner = createServerSyncContextInner(_serverSDK)
export function createServerSyncContext(serverSDK: ServerSDK) {
const inner = createServerSyncContextInner(serverSDK)
return Object.assign(inner, {
createDirSyncContext: createRefCountMap(
(dir) => createDirSyncContext(dir, inner, _serverSDK),
(dir) => createDirSyncContext(dir, inner, serverSDK),
(dir) => inner.disableMcp(dir),
directoryKey,
),
})
}
export type ServerSync = ReturnType<typeof createServerSyncContext>
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
name: "ServerSync",
gate: false,
init: (props: { server?: ServerConnection.Any }) => {
// Returns an accessor so the resolved server can change reactively without
// re-instantiating the subtree (mirrors useServerSDK).
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
const global = useGlobal()
const language = useLanguage()
const server = useServer()
const conn = props.server ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
const ctx = global.createServerCtx(conn)
return ctx.sync
return createMemo<ServerSync>(() => {
const conn = props.server?.() ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
return global.createServerCtx(conn).sync
})
},
})
export function useQueryOptions() {
return useServerSync().queryOptions
const sync = useServerSync()
return createMemo(() => sync().queryOptions)
}
+20 -5
View File
@@ -153,6 +153,15 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
gate: false,
init: () => {
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
const showCustomAgents = withFallback(
() => store.general?.showCustomAgents,
defaultSettings.general.showCustomAgents,
)
const newLayoutDesigns = withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault)
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
createEffect(() => {
if (typeof document === "undefined") return
@@ -187,7 +196,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setFollowup(value: "queue" | "steer") {
setStore("general", "followup", value === "queue" ? "steer" : value)
},
showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree),
showFileTree,
setShowFileTree(value: boolean) {
setStore("general", "showFileTree", value)
},
@@ -195,11 +204,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowNavigation(value: boolean) {
setStore("general", "showNavigation", value)
},
showSearch: withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch),
showSearch,
setShowSearch(value: boolean) {
setStore("general", "showSearch", value)
},
showStatus: withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus),
showStatus,
setShowStatus(value: boolean) {
setStore("general", "showStatus", value)
},
@@ -235,15 +244,21 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowSessionProgressBar(value: boolean) {
setStore("general", "showSessionProgressBar", value)
},
showCustomAgents: withFallback(() => store.general?.showCustomAgents, defaultSettings.general.showCustomAgents),
showCustomAgents,
setShowCustomAgents(value: boolean) {
setStore("general", "showCustomAgents", value)
},
newLayoutDesigns: withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault),
newLayoutDesigns,
setNewLayoutDesigns(value: boolean) {
setStore("general", "newLayoutDesigns", value)
},
},
visibility: {
fileTree: visible(showFileTree),
search: visible(showSearch),
status: visible(showStatus),
customAgents: visible(showCustomAgents),
},
appearance: {
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
setFontSize(value: number) {
+4 -1
View File
@@ -1,4 +1,5 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { createMemo } from "solid-js"
import { useServerSync } from "./server-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
@@ -112,5 +113,7 @@ export const useSync = () => {
const serverSync = useServerSync()
const sdk = useSDK()
return serverSync.createDirSyncContext(sdk.directory)
return createMemo(() => serverSync().createDirSyncContext(sdk().directory))
}
export type DirectorySync = ReturnType<ReturnType<typeof useSync>>
+76 -13
View File
@@ -5,7 +5,7 @@ import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
import { ServerConnection, useServer } from "./server"
import { createEffect, startTransition } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { usePlatform } from "./platform"
import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
@@ -27,6 +27,10 @@ export type DraftTab = {
export type Tab = SessionTab | DraftTab
type RecentTab = {
key?: string
}
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
export const tabHref = (tab: Tab) =>
@@ -62,25 +66,45 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
createStore<Tab[]>([]),
)
const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore<RecentTab>({}))
const params = useParams()
const navigate = useNavigate()
const location = useLocation()
const closing = new Set<string>()
let recentWrite = 0
let recentValue: string | undefined
const recentKey = () => (recentWrite ? recentValue : recent.key)
const setRecentKey = (key: string | undefined) => {
const write = ++recentWrite
recentValue = key
if (recentReady()) {
setRecent("key", key)
return
}
void recentReady.promise?.then(() => {
if (write === recentWrite) setRecent("key", key)
})
}
const removeDraftPersisted = (draftID: string) => {
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
}
createEffect(() => {
if (!ready()) return
if (!ready() || !recentReady()) return
const servers = new Set(server.list.map(ServerConnection.key))
if (store.every((tab) => servers.has(tab.server))) return
setStore((tabs) => tabs.filter((tab) => servers.has(tab.server)))
const next = store.filter((tab) => servers.has(tab.server))
if (next.length !== store.length) setStore(() => next)
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
})
const navigateTab = (tab: Tab) => {
const href = tabHref(tab)
setRecentKey(tabKey(tab))
if (tab.server === server.key) {
navigate(href)
return
@@ -123,14 +147,22 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
},
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
const active = `${location.pathname}${location.search}` === draftHref(draftID)
setStore(
produce((tabs) => {
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
if (index !== -1) tabs[index] = { type: "session", ...session }
}),
)
if (active) navigateTab({ type: "session", ...session })
// We're viewing this draft when /new-session?draftId=… points at it. Promoting
// replaces the draft tab with a session tab, so the draft route would stop resolving
// and fall back home. Navigate to the new session first so we leave /new-session
// before the draft is removed from the store.
const active = location.pathname === "/new-session" && location.query.draftId === draftID
const next = { type: "session" as const, ...session }
startTransition(() => {
setStore(
produce((tabs) => {
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
if (index !== -1) tabs[index] = next
}),
)
if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next))
if (active) navigateTab(next)
})
removeDraftPersisted(draftID)
},
removeTab: (index: number) => {
@@ -146,6 +178,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
tabs.splice(index, 1)
}),
)
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
if (nextTab) navigateTab(nextTab)
else navigate("/")
}).finally(() => closing.delete(key))
@@ -153,11 +186,22 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
removeServer(key: ServerConnection.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))
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 removed = store
.filter(
(tab) =>
tab.type === "session" &&
tab.server === server.key &&
atob(tab.dirBase64) === input.directory &&
input.sessionIDs.includes(tab.sessionId),
)
.map(tabKey)
void startTransition(() => {
setStore(
produce((tabs) => {
@@ -200,10 +244,29 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
else navigate("/")
}),
)
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
})
},
select: navigateTab,
remember(tab: Tab) {
const key = tabKey(tab)
if (recentKey() !== key) setRecentKey(key)
},
toggleHome(input: { home: boolean; current?: Tab }) {
if (input.home) {
const tab = store.find((tab) => tabKey(tab) === recentKey())
if (tab) navigateTab(tab)
return
}
if (input.current) {
setRecentKey(tabKey(input.current))
navigate("/")
return
}
navigate("/")
},
}
return { ...actions, store, ready }
return { ...actions, store, ready, recentReady }
},
})
@@ -9,6 +9,8 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
+5 -5
View File
@@ -2,7 +2,7 @@ import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import { useSDK, type DirectorySDK } from "./sdk"
import type { Platform } from "./platform"
import { useServer } from "./server"
import { defaultTitle, titleNumber } from "./terminal-title"
@@ -143,7 +143,7 @@ export function clearWorkspaceTerminals(
}
function createWorkspaceTerminalSession(
sdk: ReturnType<typeof useSDK>,
sdk: DirectorySDK,
dir: string,
scope: ServerScopeValue,
legacySessionID?: string,
@@ -202,7 +202,7 @@ function createWorkspaceTerminalSession(
})
onCleanup(unsub)
const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
const index = store.all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
@@ -223,7 +223,7 @@ function createWorkspaceTerminalSession(
})
}
const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
const clone = async (client: DirectorySDK["client"], id: string) => {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
@@ -412,7 +412,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
const entry = createRoot((dispose) => ({
value: createWorkspaceTerminalSession(sdk, dir, serverScope, legacySessionID),
value: createWorkspaceTerminalSession(sdk(), dir, serverScope, legacySessionID),
dispose,
}))
+1 -1
View File
@@ -141,7 +141,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [
id: "view",
label: "View",
items: [
{ type: "item", label: "Toggle Sidebar", command: "sidebar.toggle", accelerator: { macos: "Cmd+B" } },
{ type: "item", label: "Toggle Sidebar", command: "sidebar.toggle" },
{ type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } },
{ type: "item", label: "Toggle File Tree", command: "fileTree.toggle" },
{ type: "separator" },
+2 -2
View File
@@ -22,10 +22,10 @@ export function useProviders() {
const dir = createMemo(() => decode64(params.dir) ?? "")
const providers = () => {
if (dir()) {
const [projectStore] = serverSync.child(dir())
const [projectStore] = serverSync().child(dir())
if (projectStore.provider_ready) return projectStore.provider
}
return serverSync.data.provider
return serverSync().data.provider
}
return {
all: () => providers().all,

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