Compare commits

...

140 Commits

Author SHA1 Message Date
Kit Langton 76fffd1081 fix(server): preserve event API requirements 2026-06-23 23:01:24 -04:00
Kit Langton 50613405b4 refactor(schema): extract public event definitions 2026-06-23 22:58:10 -04:00
Kit Langton 57895586c2 refactor(core): remove schema forwarding facades (#33577) 2026-06-24 02:52:58 +00:00
Brendan Allan 6298db7a77 fix(app): throttle directory tree loading (#33576) 2026-06-24 02:51:26 +00:00
opencode-agent[bot] 5202bf1bf2 chore: update nix node_modules hashes 2026-06-24 02:44:09 +00:00
Kit Langton 516cfe4e09 refactor(schema): extract shared public schemas (#33571) 2026-06-23 22:31:06 -04:00
Brendan Allan 60aba622ab fix(app): use fixed titlebar tab widths (#33572) 2026-06-24 02:05:44 +00:00
Dax Raad e8610d821c refactor(core): drop filesystem entry mime 2026-06-23 21:49:01 -04:00
Dax Raad 53076999c5 fix(tui): normalize autocomplete attachments 2026-06-23 21:41:14 -04:00
Brendan Allan f8c9bfd454 fix(app): mount shortcuts per titlebar tab (#33567) 2026-06-24 01:39:30 +00:00
Brendan Allan fed4f4d86b feat(app): keep prompt state in tabs (#33566) 2026-06-24 01:32:59 +00:00
opencode-agent[bot] 1490752059 chore: generate 2026-06-24 00:54:55 +00:00
Dax 4898263dec feat(core): map providers to integrations (#33562) 2026-06-24 00:53:31 +00:00
Dax c556bddda3 feat(core): add opencode integration (#33560) 2026-06-24 00:26:54 +00:00
opencode-agent[bot] cfddb2407c chore: generate 2026-06-23 23:46:36 +00:00
Dax cf80b5c470 feat(core): add opencode integration (#33555) 2026-06-23 23:45:02 +00:00
Aiden Cline c17b9557f1 fix(core): preserve structured error messages (#33530) 2026-06-23 18:43:59 -05:00
opencode-agent[bot] 10f6bb7878 chore: generate 2026-06-23 23:24:49 +00:00
Aiden Cline b8cfd69acf feat(tui): redesign crash screen (#33549) 2026-06-23 18:23:15 -05:00
opencode-agent[bot] d2305d4a76 chore: generate 2026-06-23 20:31:13 +00:00
Shoubhit Dash c6cc13e183 feat(mcp): add resource template listing (#33546) 2026-06-24 01:59:12 +05:30
Kit Langton dcf7b4e792 fix(opencode): handle snapshot paths from subdirectories (#33506) 2026-06-23 20:51:56 +02:00
Aiden Cline 5152150bfe fix(opencode): make ACP resource text sourcing cross-platform (#33534) 2026-06-23 12:55:34 -05:00
Brendan Allan 2ba18b84a5 fix(app): use correct server sdk for titlebar session lookup (#33536) 2026-06-24 01:54:43 +08:00
Brendan Allan e04c5e72f7 fix(app): prompt persistence and draft sessions (#33528) 2026-06-24 01:50:19 +08:00
Aiden Cline a131811cdc feat(mcp): use mcp__server__tool naming convention (#33533) 2026-06-23 12:28:26 -05:00
opencode-agent[bot] 976e5d421f ci: route support questions (#33527)
Co-authored-by: Test <test@opencode.test>
2026-06-23 12:20:30 -05:00
Shoubhit Dash 8e2d422ffe fix(acp): preserve resource text source (#33524) 2026-06-23 22:15:00 +05:30
Aiden Cline a3825286cf test(core): avoid models cache recovery race (#33525) 2026-06-23 11:40:08 -05:00
ReStranger e0c0411003 fix: Skip bun version check for nix version (#33166)
Signed-off-by: ReStranger <restranger@disroot.org>
Co-authored-by: dbeley <6568955+dbeley@users.noreply.github.com>
2026-06-23 11:10:55 -05:00
opencode-agent[bot] e439456b13 chore: generate 2026-06-23 15:47:46 +00:00
Aiden Cline e3edbba3ca test(opencode): stabilize prompt concurrency tests (#33522) 2026-06-23 10:45:30 -05:00
Simon Klee 3cdd431794 fix(tui): make move session command bindable (#33508)
Add the session_move TUI keybind and map it to the existing session.move command. Keep it unbound by default and document the new configuration option.

Closes #33239
2026-06-23 15:55:14 +02:00
Adam ef4500d6af fix(data): ignore table changes
deploy / deploy (push) Has been cancelled
2026-06-23 07:28:11 -05:00
Adam f416f52ae3 Reapply "feat(stats): add user ids to inference metrics"
This reverts commit e0ff4fd1ac.
2026-06-23 07:27:01 -05:00
Adam e0ff4fd1ac Revert "feat(stats): add user ids to inference metrics"
This reverts commit 428ae6d21a.
2026-06-23 07:11:51 -05:00
Adam 428ae6d21a feat(stats): add user ids to inference metrics 2026-06-23 07:05:26 -05:00
Brendan Allan d568fed095 fix(app): tighten mobile utility UI (#32799) 2026-06-23 10:41:05 +00:00
opencode-agent[bot] 58a8d9fcb0 chore: generate 2026-06-23 10:28:43 +00:00
Aarav Sareen d24848359d feat(app): new session progress indicator (#32662)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-06-23 20:27:07 +10:00
Brendan Allan a21e74773f fix(app): improve iOS PWA shell (#32798) 2026-06-23 09:59:30 +00:00
opencode-agent[bot] fc95a84b42 chore: generate 2026-06-23 09:30:58 +00:00
Brendan Allan b0063709e6 feat(app): add mobile bottom navigation (#32797) 2026-06-23 09:29:22 +00:00
opencode-agent[bot] 77522697b0 chore: generate 2026-06-23 09:00:19 +00:00
Brendan Allan bbc88eb8e3 fix(app): refine mobile session layout (#32796) 2026-06-23 16:58:05 +08:00
opencode-agent[bot] a379c7956b chore: generate 2026-06-23 08:47:47 +00:00
Aarav Sareen 52eae83ae4 fix(app): add server button dropdown (#33358) 2026-06-23 18:46:00 +10:00
Aarav Sareen 3cc626b5da feat(app): collapsible servers (#33384) 2026-06-23 18:41:54 +10:00
Luke Parker f0849a697c fix(app): reject stale timeline range indexes (#33488) 2026-06-23 18:27:25 +10:00
Shoubhit Dash 3f3f120825 feat(mcp): add resource read tools (#33483) 2026-06-23 13:49:42 +05:30
Frank ea17d9bed8 zen: glm 5.2 2026-06-23 04:03:07 -04:00
Frank 726b2d7e6b zen: remove unused fields 2026-06-23 04:03:07 -04:00
opencode-agent[bot] bffc5938fa chore: generate 2026-06-23 08:02:37 +00:00
Luke Parker d111812389 fix(app): restore review line comments (#33481) 2026-06-23 08:01:03 +00:00
Brendan Allan 597f47b486 fix(app): improve mobile home layout (#32789) 2026-06-23 16:00:21 +08:00
opencode-agent[bot] 09757c605a chore: generate
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-06-23 06:20:50 +00:00
Brendan Allan 0c4f508c50 feat(app): add server-keyed session routes (#32570) 2026-06-23 06:19:18 +00:00
opencode-agent[bot] 40db33c415 chore: generate 2026-06-23 04:57:55 +00:00
Dax Raad 9b01f15f1d test(opencode): retry Windows CLI cleanup 2026-06-23 00:56:14 -04:00
Dax Raad 81851ca6b9 test(opencode): stabilize Windows async readiness 2026-06-23 00:41:02 -04:00
Dax Raad 3c5632e110 test(opencode): stabilize Windows CLI subprocesses 2026-06-23 00:26:48 -04:00
Dax Raad 6ea3e6698a fix(opencode): scope reference readiness wait 2026-06-23 00:10:36 -04:00
Dax Raad ed75ce9ecc fix(core): prioritize reference plugin registration 2026-06-22 23:39:26 -04:00
Dax Raad af14fefc96 test(opencode): relax compaction readiness timeout 2026-06-22 23:01:05 -04:00
Dax Raad 4a710e4679 fix(core): await plugin readiness 2026-06-22 22:49:57 -04:00
Dax Raad d2c866bf70 test(core): speed up test setup 2026-06-22 22:49:57 -04:00
opencode-agent[bot] 237595e242 chore: generate 2026-06-23 02:19:31 +00:00
Aiden Cline d29f5eba92 refactor(core): remove shell description input (#32823) 2026-06-22 21:18:06 -05:00
Aiden Cline fbf889db83 fix(tui): preserve worker rejection handling (#33448)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-06-22 19:57:21 -05:00
Dax ef2357915e fix(tui): scope file autocomplete to session (#33458) 2026-06-22 20:31:36 -04:00
Dax Raad 975b1132f1 refactor(plugin): use direct runtime registry 2026-06-22 20:10:29 -04:00
Dax Raad cd97de7391 refactor(plugin): consolidate internal registration 2026-06-22 19:49:00 -04:00
Dax Raad 23fd5907be fix(opencode): normalize CLI test line endings 2026-06-22 19:15:50 -04:00
opencode-agent[bot] 49d3f86802 chore: generate 2026-06-22 23:08:26 +00:00
Dax 909a1a6d78 feat(plugin): add namespaced hook API (#33416) 2026-06-22 19:06:57 -04:00
Kit Langton dc468bdcfd fix(core): reset steps for promoted prompts (#33452) 2026-06-22 19:01:14 -04:00
Kit Langton f48f24ec4e refactor(core): simplify session input promotion (#33443) 2026-06-22 17:51:49 -04:00
Aiden Cline 34b3d59a23 ignore: update agents.md (#33446) 2026-06-22 15:52:43 -05:00
Frank a0a500316e zen: new inference
deploy / deploy (push) Has been cancelled
2026-06-22 15:40:14 -04:00
Kit Langton 1787fa4261 refactor(core): drop legacy compaction event (#33404) 2026-06-22 21:39:41 +02:00
Kit Langton 130957288e fix(llm): preserve structured tool errors (#33405) 2026-06-22 13:43:07 -04:00
Aiden Cline c5a4a8288c fix: dont show gpt-5.5-pro when using codex oauth (#33400)
Co-authored-by: Devin Oldenburg <devin@logicplanes.com>
2026-06-22 11:47:02 -05:00
Adam 39d7394ede fix(stats): hide unique users tooltip total 2026-06-22 11:40:00 -05:00
opencode-agent[bot] 494123a875 chore: generate 2026-06-22 16:18:36 +00:00
Kit Langton fe840d42b8 refactor(core): simplify session run coordination (#33388) 2026-06-22 12:16:30 -04:00
Kit Langton f50e4accf3 test(opencode): synchronize shell cancellation (#33386) 2026-06-22 11:55:04 -04:00
Kit Langton 9bceb8eb7d test(opencode): synchronize websocket retry failures (#33387) 2026-06-22 11:54:49 -04:00
Kit Langton adebb87191 test(opencode): use EventV2 location contract (#33383) 2026-06-22 11:37:43 -04:00
opencode-agent[bot] 4d5efba5c9 chore: generate 2026-06-22 15:36:00 +00:00
Kit Langton c6ee511485 refactor(core): simplify session context epochs (#33378) 2026-06-22 11:34:03 -04:00
Kit Langton f9f2280452 fix(core): defer session model validation (#33377) 2026-06-22 11:07:27 -04:00
Tommy D. Rossi d5980b47e9 feat(llm): add video and audio media support to Gemini protocol (#31889)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-06-22 10:03:19 -05:00
Kit Langton 41d1279b6f fix(opencode): preserve request logger context (#33381) 2026-06-22 10:58:22 -04:00
Simon Klee 0d32d1f293 cli: add --mini (#33353) 2026-06-22 16:27:56 +02:00
Dax Raad cf31029350 fix(core): batch plugin shutdown 2026-06-22 09:04:29 -04:00
Dax Raad 79b55d4db8 fix(tui): use bridged event stream for data 2026-06-22 08:50:46 -04:00
Adam 36264ccf90 fix(stats): format market share tokens 2026-06-22 07:46:11 -05:00
Dax Raad 7b750a8f20 fix(ci): preserve test log groups 2026-06-22 08:35:40 -04:00
Dax Raad e50261e524 fix(core): format generated migrations 2026-06-22 08:23:25 -04:00
opencode-agent[bot] 639c94a375 chore: generate 2026-06-22 12:22:43 +00:00
Dax Raad b13a2d712a fix(server): isolate event schema types 2026-06-22 08:20:39 -04:00
Dax Raad 1b91b5df34 fix(core): constrain event schema services 2026-06-22 08:14:49 -04:00
Dax Raad 595cc91ddc feat(server): stream events across locations 2026-06-22 08:12:43 -04:00
Dax 57ce1b9ca8 fix(cli): increment ports from default (#33282) 2026-06-22 08:05:49 -04:00
opencode-agent[bot] 4ecc3ac653 chore: generate 2026-06-22 11:22:22 +00:00
Simon Klee c7efbe6fc0 run: inline files for attached servers (#33317) 2026-06-22 13:20:43 +02:00
opencode-agent[bot] cd292a4ecb chore: generate
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-06-22 04:17:10 +00:00
Dax 2bb4311042 refactor(core): simplify integration test fixtures (#33292) 2026-06-22 04:15:34 +00:00
opencode-agent[bot] cdc6d01c5a chore: generate 2026-06-22 04:00:59 +00:00
Dax Raad 35b3fc85d0 feat(core): expose session switching endpoints 2026-06-21 23:59:22 -04:00
Aiden Cline 9dadc2455f fix(tui): render skill load errors inline (#33298) 2026-06-21 22:40:12 -05:00
opencode-agent[bot] 06dae383f7 chore: generate 2026-06-21 23:42:07 +00:00
Adam 7d204b5b57 feat(stats): show model unique users
deploy / deploy (push) Has been cancelled
2026-06-21 18:40:22 -05:00
Kit Langton 49593c1ec4 fix(core): settle interrupted assistant steps (#33266) 2026-06-21 20:22:09 +00:00
Kit Langton ff837fe949 fix(core): handle read file failures (#33260) 2026-06-21 20:12:42 +00:00
Adam 4c6750d464 fix(stats): unblock stats sync 2026-06-21 14:53:46 -05:00
Kit Langton 69f1ec22e3 fix(core): bound web tool failures (#33259) 2026-06-21 15:37:25 -04:00
Kit Langton 823d327401 fix(core): handle missing read paths (#33255) 2026-06-21 13:50:14 -04:00
opencode-agent[bot] 82d9cab48d chore: generate 2026-06-21 14:36:19 +00:00
Dax fb43c15f88 refactor(core): simplify event model (#33238) 2026-06-21 10:34:57 -04:00
opencode-agent[bot] ca006a2d20 chore: update nix node_modules hashes 2026-06-21 12:21:25 +00:00
Dax Raad 8396395f17 fix(stats): update defect schemas 2026-06-21 08:08:31 -04:00
Dax Raad 1b8bab3e35 fix(ci): avoid Playwright Chromium install hang 2026-06-21 08:08:31 -04:00
opencode-agent[bot] 02687b6324 chore: generate 2026-06-21 12:07:16 +00:00
Dax c780d7cee7 feat(plugin): add v2 effect host (#33111) 2026-06-21 08:05:49 -04:00
opencode-agent[bot] 7a9337da8a chore: generate 2026-06-21 11:49:50 +00:00
Adam f96e6aa6ed fix(stats): bypass worker runtime crash 2026-06-21 06:48:25 -05:00
Adam c6f719e153 fix(stats): restore worker data exports 2026-06-21 06:39:49 -05:00
Adam 1a111be494 fix(stats): run worker effects directly 2026-06-21 06:30:15 -05:00
Adam a0aee82be9 fix(stats): inline worker runtime import 2026-06-21 06:22:26 -05:00
Adam c0dc6e50a7 fix(stats): restore worker runtime 2026-06-21 06:16:02 -05:00
Adam 418a9e4e66 fix(stats): restore model page rendering 2026-06-21 05:58:39 -05:00
Adam bd8ce5e6a9 chore(stats): remove deploy migrations 2026-06-21 05:41:10 -05:00
Adam a97c6de9db fix(stats): support planetscale cli variants 2026-06-21 05:17:22 -05:00
opencode-agent[bot] 24ea4dd008 chore: generate 2026-06-21 10:03:16 +00:00
Adam ffcb7542e1 fix(stats): run production migration safely 2026-06-21 05:01:43 -05:00
opencode-agent[bot] d4d841bafd chore: generate 2026-06-21 09:40:15 +00:00
Adam 6f0e934573 fix(stats): make unique users migration idempotent 2026-06-21 04:38:47 -05:00
Adam 233d065dd5 feat(stats): show model users metric 2026-06-21 04:30:25 -05:00
Dax f12ac6f234 fix(tui): reduce noisy MCP autocomplete matches (#33176)
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
containers / build (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-06-20 22:24:59 -04:00
520 changed files with 26629 additions and 13234 deletions
+1 -1
View File
@@ -2,4 +2,4 @@ blank_issues_enabled: false
contact_links:
- name: 💬 Discord Community
url: https://discord.gg/opencode
about: For quick questions or real-time discussion. Note that issues are searchable and help others with the same question.
about: For support, troubleshooting, how-to questions, and real-time discussion.
-10
View File
@@ -1,10 +0,0 @@
name: Question
description: Ask a question
body:
- type: textarea
id: question
attributes:
label: Question
description: What's your question?
validations:
required: true
+3 -2
View File
@@ -65,7 +65,7 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
run: GITHUB_ACTIONS=false bun turbo test
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
@@ -99,7 +99,8 @@ jobs:
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "24"
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
node-version: "24.15"
- name: Setup Bun
uses: ./.github/actions/setup-bun
+3 -3
View File
@@ -137,7 +137,7 @@ const table = sqliteTable("session", {
## Testing
- Avoid mocks as much as possible
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
@@ -152,7 +152,7 @@ const table = sqliteTable("session", {
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work.
- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
+23 -9
View File
@@ -24,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta
_Avoid_: System update, system notification, raw text diff
**Context Epoch**:
The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline.
**Baseline System Context**:
The full **System Context** rendered at the start of a **Context Epoch**.
@@ -39,6 +39,18 @@ An expected temporary inability to observe a **Context Source** value; the runti
**Safe Provider-Turn Boundary**:
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
**Admitted Prompt**:
A durable user input accepted into the Session inbox but not yet included in **Session History**.
**Prompt Promotion**:
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
**Provider Turn**:
One request to a model provider and the response projected from that request.
**Session Drain**:
One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
**Model Tool Output**:
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
@@ -67,6 +79,11 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
@@ -75,31 +92,28 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
- `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context.
- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run.
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move.
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
- Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent.
- A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged baseline.
- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn.
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline.
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- A **Context Epoch** begins with one immutable **Baseline System Context**.
- A **Context Epoch** durably records the effective agent that owns its **Baseline System Context**.
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
- **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`.
+17
View File
@@ -276,6 +276,8 @@
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
@@ -479,6 +481,7 @@
"name": "@opencode-ai/llm",
"version": "1.17.9",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
"aws4fetch": "1.0.20",
@@ -625,6 +628,7 @@
"name": "@opencode-ai/plugin",
"version": "1.17.9",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
"zod": "catalog:",
@@ -649,6 +653,17 @@
"@opentui/solid",
],
},
"packages/schema": {
"name": "@opencode-ai/schema",
"dependencies": {
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/script": {
"name": "@opencode-ai/script",
"dependencies": {
@@ -1802,6 +1817,8 @@
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
+2 -1
View File
@@ -56,6 +56,7 @@ const inferenceEventTable = new aws.s3tables.Table(
{ name: "error_cause2", type: "string", required: false },
{ name: "api_key", type: "string", required: false },
{ name: "workspace", type: "string", required: false },
{ name: "user_id", type: "string", required: false },
{ name: "is_subscription", type: "boolean", required: false },
{ name: "subscription", type: "string", required: false },
{ name: "response_length", type: "long", required: false },
@@ -84,7 +85,7 @@ const inferenceEventTable = new aws.s3tables.Table(
},
},
},
{ deleteBeforeReplace: $app.stage !== "production" },
{ deleteBeforeReplace: $app.stage !== "production", ignoreChanges: ["metadata"] },
)
export const inferenceEvent = new sst.Linkable("InferenceEvent", {
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-g0tDvRf7MErZ1PEeUazEYi492ZHiRT8kYv3bPdkss/I=",
"aarch64-linux": "sha256-6sKgf3ftbIqlPxlFkoPzoWPsJp3IwXD+H3Y6g874xmk=",
"aarch64-darwin": "sha256-Se/Nls/KlkuK2ysDQ9DeAzSaX3NsL2iDdf/dsv2GIXc=",
"x86_64-darwin": "sha256-V9MCkqnvQ1nkD2PaaTfNFKkBZGymj6KxrSAK6+DTF8Y="
"x86_64-linux": "sha256-/fq73Jr1mTysaKS0u7VUuwhFzz9tIlYw+9qyW5pA7es=",
"aarch64-linux": "sha256-iomnsCjOvab+v7+ebHoyg/Q7YtQegkln1+RTkb/tLn8=",
"aarch64-darwin": "sha256-7Uaxm1YlhblvtrVSFXPAx2zVjhYmhkjGkCA78bqs6Xg=",
"x86_64-darwin": "sha256-yJbe3TC8wlQJvQP7IwI+9USpsAjlwX7m5Jqa/7dW7bQ="
}
}
+7
View File
@@ -27,6 +27,13 @@ stdenvNoCC.mkDerivation (finalAttrs: {
writableTmpDirAsHomeHook
];
postPatch = ''
# NOTE: Relax Bun version check to be a warning instead of an error
substituteInPlace packages/script/src/index.ts \
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
'';
configurePhase = ''
runHook preConfigure
@@ -0,0 +1,157 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewLineCommentRegression"
const sessionID = "ses_review_line_comment_regression"
const title = "Review line comment regression"
test.beforeEach(async ({ page }) => {
await openReview(page)
})
test("opens the comment editor when code is clicked", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const line = review.getByText("export const value = 'after'", { exact: true })
await expectAppVisible(line)
await line.click()
await expect(review.getByRole("textbox")).toBeVisible()
})
test("opens the comment editor when a line number is clicked", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)
await lineNumber.click()
await expect(review.getByRole("textbox")).toBeVisible()
})
test("opens the comment editor for a line number range", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const start = review.locator('[data-column-number="1"]').last()
const end = review.locator('[data-column-number="3"]').last()
await expectAppVisible(start)
await expectAppVisible(end)
const from = await start.boundingBox()
const to = await end.boundingBox()
if (!from || !to) throw new Error("Missing line number bounds")
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
await page.mouse.down()
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2)
await page.mouse.up()
await expect(review.getByRole("textbox")).toBeVisible()
})
test("shows a comment button when a line number is hovered", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)
const comment = review.getByRole("button", { name: "Comment", exact: true })
await expect(async () => {
await page.mouse.move(0, 0)
await lineNumber.hover()
await expect(comment).toBeVisible({ timeout: 500 })
}).toPass()
await comment.click()
await expect(review.getByRole("textbox")).toBeVisible()
})
test("stages a submitted line comment in the prompt context", async ({ page }) => {
const requests: string[] = []
page.on("request", (request) => {
if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`)
})
const review = page.locator('[data-component="session-review"]')
await review.getByText("export const value = 'after'", { exact: true }).click()
await review.getByRole("textbox").fill("Use the existing value instead")
await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click()
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "Session" }).click()
const context = page.getByText("Use the existing value instead", { exact: true }).last()
await expect(context).toBeVisible()
await expect(context.locator("..")).toContainText("review.ts:2")
expect(requests).toEqual([])
})
async function openReview(page: Page) {
await page.setViewportSize({ width: 700, height: 900 })
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_review_line_comment_regression",
worktree: directory,
vcs: "git",
name: "review-line-comment-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: sessionID,
slug: "review-line-comment-regression",
projectID: "proj_review_line_comment_regression",
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [
{
file: "src/review.ts",
additions: 1,
deletions: 1,
status: "modified",
patch:
"diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
},
],
pageMessages: () => ({
items: [
{
info: {
id: "msg_review_line_comment_regression",
sessionID,
role: "user",
time: { created: 1700000000000 },
summary: { diffs: [] },
agent: "build",
model: { providerID: "opencode", modelID: "test" },
},
parts: [
{
id: "prt_review_line_comment_regression",
sessionID,
messageID: "msg_review_line_comment_regression",
type: "text",
text: "Review this change.",
},
],
},
],
}),
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
await page.getByRole("tab", { name: "Changes" }).click()
expect(await (await diffResponse).json()).toHaveLength(1)
const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review)
await review
.getByRole("heading", { name: /review\.ts/ })
.getByRole("button")
.first()
.click()
}
@@ -21,6 +21,7 @@ const words = [
"vector",
]
const serverKey = "http://127.0.0.1:4096"
const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject"
@@ -139,7 +140,7 @@ function toolPart(
status: "completed",
input,
output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
metadata,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
},
@@ -200,9 +201,7 @@ function turn(index: number): Message[] {
...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []),
...(index % 7 === 0
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []),
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
...(index % 13 === 0
@@ -242,6 +241,7 @@ function orderedParts(message: Message) {
export const fixture = {
directory,
serverKey,
project: {
id: projectID,
worktree: directory,
@@ -295,6 +295,7 @@ export const fixture = {
.filter(renderable)
.map((part) => part.id),
),
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
},
}
@@ -327,6 +327,18 @@ test.describe("smoke: session timeline", () => {
const expectedMessageIDs = fixture.expected.targetMessageIDs
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(shellSubtitle).toHaveCount(0)
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
await shellTrigger.click()
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
await expect(shellSubtitle).toHaveText("bun typecheck")
await shellTrigger.click()
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
await expect(shellSubtitle).toHaveCount(0)
})
})
@@ -706,7 +718,8 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
}
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
const href = `/${base64Encode(fixture.directory)}/session/${sessionID}`
console.log(process.env)
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
+2
View File
@@ -18,6 +18,7 @@ export interface MockServerConfig {
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
vcsDiff?: unknown[]
messageDelay?: number
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
events?: () => unknown[]
@@ -52,6 +53,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
if (path === "/global/health") return json(route, { healthy: true })
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path in staticRoutes) return json(route, staticRoutes[path])
+11 -5
View File
@@ -1,22 +1,28 @@
<!doctype html>
<html lang="en" style="background-color: var(--background-base)">
<html lang="en" style="background-color: var(--v2-background-bg-deep, #fafafa)">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
/>
<title>OpenCode</title>
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
<link rel="shortcut icon" href="/favicon-v3.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#fafafa" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<body class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh p-px"></div>
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
<script src="/src/entry.tsx" type="module"></script>
</body>
</html>
+2 -1
View File
@@ -15,10 +15,11 @@
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
document.documentElement.style.backgroundColor = isDark ? "#080808" : "#fafafa"
// Update theme-color meta tag to match app color scheme
var metas = document.querySelectorAll("meta[name='theme-color']")
if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#131010" : "#F8F7F7")
if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#080808" : "#fafafa")
if (themeId === "oc-2") return
+200 -63
View File
@@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { keepPreviousData, QueryClient, QueryClientProvider, useQuery } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
type Component,
@@ -30,7 +30,7 @@ import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
@@ -47,11 +47,14 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route"
const HomeRoute = lazy(() => import("@/pages/home"))
const LegacyHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome })))
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome })))
const Session = lazy(() => import("@/pages/session"))
const NewSession = lazy(() => import("@/pages/new-session"))
@@ -64,6 +67,10 @@ const SessionRoute = Object.assign(
const server = useServer()
const tabs = useTabs()
if (params.id && settings.general.newLayoutDesigns()) {
return <Navigate href={sessionHref(server.key, params.id)} />
}
// 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(() => {
@@ -82,29 +89,55 @@ const SessionRoute = Object.assign(
{ preload: Session.preload },
)
const TargetSessionRoute = Object.assign(
() => {
const sdk = useSDK()
const serverSDK = useServerSDK()
return (
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
<SessionProviders>
<Session />
</SessionProviders>
</Show>
)
},
{ 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) {
function SelectedServerProviders(props: ParentProps) {
return (
<ServerKey>
<ServerSDKProvider>
<ServerSyncProvider>
<ServerScopedShell>{props.children}</ServerScopedShell>
</ServerSyncProvider>
<ServerSyncProvider>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
</ServerKey>
)
}
function LegacyServerLayout(props: ParentProps) {
return (
<SelectedServerProviders>
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
</SelectedServerProviders>
)
}
// 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) {
function TargetServerLayout(props: ParentProps) {
const server = useServer()
const tabs = useTabs()
const params = useParams<{ serverKey?: string }>()
const [search] = useSearchParams<{ draftId?: string }>()
const conn = createMemo(() => {
if (params.serverKey) {
const key = requireServerKey(params.serverKey)
return server.list.find((item) => ServerConnection.key(item) === key)
}
const id = search.draftId
if (!id) return undefined
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
@@ -115,48 +148,100 @@ function DraftServerLayout(props: ParentProps) {
return (
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ServerScopedShell>{props.children}</ServerScopedShell>
<TargetDirectoryLayout>{props.children}</TargetDirectoryLayout>
</ServerSyncProvider>
</ServerSDKProvider>
)
}
function TargetDirectoryLayout(props: ParentProps) {
const params = useParams<{ serverKey?: string; id?: string }>()
const [search] = useSearchParams<{ draftId?: string }>()
const settings = useSettings()
const tabs = useTabs()
const serverSDK = useServerSDK()
const serverKey = createMemo(() => {
if (params.serverKey) return requireServerKey(params.serverKey)
if (!search.draftId) return undefined
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.server
})
const resolved = useQuery(() => ({
queryKey: [serverSDK().scope, "session-route", params.id] as const,
enabled: !!params.serverKey && !!params.id,
placeholderData: keepPreviousData,
queryFn: async () => {
const session = (await serverSDK().client.session.get({ sessionID: params.id! })).data!
const root = await rootSession(session, (sessionID) =>
serverSDK()
.client.session.get({ sessionID })
.then((result) => result.data!),
)
return { session, rootID: root.id }
},
}))
const resolvedDirectory = createMemo(() => {
if (params.serverKey) return resolved.data?.session.directory
if (!search.draftId) return undefined
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory
})
const directory = createMemo<string | undefined>((prev) =>
search.draftId ? resolvedDirectory() : (prev ?? resolvedDirectory()),
)
const home = () => !params.serverKey && !search.draftId
const targetDirectory = () => directory()!
createEffect(() => {
const current = resolved.data
const key = serverKey()
if (!current || !key) return
tabs.addSessionTab({
server: key,
sessionId: current.rootID,
})
})
return (
<NewServerScopedShell directory={() => (home() ? undefined : directory())} sessionID={() => params.id}>
<Show when={!home()} fallback={props.children}>
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
<Show when={directory()}>
<Show
when={!params.serverKey || settings.general.newLayoutDesigns()}
fallback={<Navigate href={legacySessionHref(directory()!, params.id!)} />}
>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<Show when={!params.serverKey || (resolved.data && !resolved.isPlaceholderData)}>
{props.children}
</Show>
</DirectoryDataProvider>
</SDKProvider>
</Show>
</Show>
</Show>
</Show>
</NewServerScopedShell>
)
}
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} />}
<ResolvedDraftRoute />
</Show>
</Show>
)
}
function ResolvedDraftRoute(props: { draftID: string }) {
const tabs = useTabs()
const draft = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
)
// Key on the directory so retargeting the draft's project re-instantiates the
// directory-scoped providers while keeping the same draft id. The draft's target
// server is provided by DraftServerLayout, so changing only the server updates the
// SDK/sync hooks without remounting the composer.
const directory = () => draft()?.directory
function ResolvedDraftRoute() {
return (
<Show when={directory()} keyed>
{(dir) => (
<SDKProvider directory={dir}>
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
)}
</Show>
<DraftProviders>
<NewSession />
</DraftProviders>
)
}
@@ -210,32 +295,51 @@ function BodyDesignClass() {
// shell (router root) so they stay mounted regardless of the active server/route.
function SharedProviders(props: ParentProps) {
return (
<SettingsProvider>
<>
<BodyDesignClass />
<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) {
type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
sessionID?: () => string | undefined
}>
function ServerScopedProviders(props: ServerScopedShellProps) {
return (
<PermissionProvider>
<PermissionProvider directory={props.directory}>
<LayoutProvider>
<NotificationProvider>
<ModelsProvider>
<Layout>{props.children}</Layout>
</ModelsProvider>
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider>{props.children}</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
)
}
function LegacyServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
<LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders>
)
}
function NewServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
<NewLayout>{props.children}</NewLayout>
</ServerScopedProviders>
)
}
function SessionProviders(props: ParentProps) {
return (
<TerminalProvider>
@@ -439,28 +543,61 @@ export function AppInterface(props: {
servers={props.servers}
>
<GlobalProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<ServerShell>{routerProps.children}</ServerShell>
</TabsProvider>
)}
>
<Route component={SelectedServerLayout}>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Route>
<Route component={DraftServerLayout}>
<Route path="/new-session" component={DraftRoute} />
</Route>
</Dynamic>
</ConnectionGate>
<SettingsProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<ServerShell>{routerProps.children}</ServerShell>
</TabsProvider>
)}
>
<Routes />
</Dynamic>
</Show>
</ConnectionGate>
</SettingsProvider>
</GlobalProvider>
</ServerProvider>
)
}
function Routes() {
const settings = useSettings()
return (
<>
<Route component={LegacyServerLayout}>
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Route>
<Route component={TargetServerLayout}>
<Show when={settings.general.newLayoutDesigns()}>
{
<>
<Route path="/" component={NewHome} />
<Route path="/:dir" component={DirectoryLayout}>
<Route
path="/session/:id"
component={() => {
const server = useServer()
const { id } = useParams()
return <Navigate href={`/server/${server.key}/session/${id}`} />
}}
/>
</Route>
</>
}
</Show>
<Route path="/new-session" component={DraftRoute} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</Route>
</>
)
}
+1 -1
View File
@@ -363,7 +363,7 @@ export function DebugBar() {
return (
<aside
aria-label={language.t("debugBar.ariaLabel")}
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
class="pointer-events-auto fixed bottom-3 right-3 z-50 hidden w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] md:block sm:bottom-4 sm:right-4 sm:w-[324px]"
>
<div class="grid grid-cols-5 gap-px font-mono">
<Cell
@@ -19,6 +19,7 @@ import {
pickerMode,
preloadTreeDirectories,
cleanPickerInput,
createPriorityTaskQueue,
createDirectorySearch,
currentPickerSuggestions,
displayPickerPath,
@@ -55,6 +56,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const [error, setError] = createSignal(false)
const [rootValid, setRootValid] = createSignal(false)
const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>()
const loads = createPriorityTaskQueue<Array<{ name: string; type: "file" | "directory" }> | undefined>(3)
const advanced = new Set<string>()
let tree: FileTree | undefined
let container: HTMLDivElement | undefined
@@ -102,16 +104,21 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
})
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
async function load(path: string, generation: number, preload = true) {
async function load(path: string, generation: number, eager = false) {
const key = path.replace(/\/+$/, "")
setError(false)
const absolute = absoluteTreePath(root(), key)
const existing = listings.get(key)
if (existing && !eager) loads.promote(`${generation}:${key}`)
const request =
listings.get(key) ??
sdk.client.file
.list({ directory: absolute, path: "" })
.then((result) => result.data ?? [])
.catch(() => undefined)
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return 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
@@ -121,8 +128,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
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)))
if (!eager && advanceTreePreload(advanced, key)) {
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
}
return true
}
@@ -15,6 +15,7 @@ import {
treePathWithin,
currentPickerSuggestions,
createDirectorySearch,
createPriorityTaskQueue,
displayPickerPath,
pickerParent,
pickerRoot,
@@ -168,6 +169,41 @@ test("advances preloading once for every expanded directory", () => {
expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
})
test("limits background tasks and prioritizes newly requested work", async () => {
const queue = createPriorityTaskQueue<void>(2)
const first = Promise.withResolvers<void>()
const second = Promise.withResolvers<void>()
const started: string[] = []
let active = 0
let maximum = 0
const task = (name: string, blocker?: Promise<void>) => async () => {
started.push(name)
active++
maximum = Math.max(maximum, active)
await blocker
active--
}
const running = [
queue.schedule("first", "background", task("first", first.promise)),
queue.schedule("second", "background", task("second", second.promise)),
queue.schedule("preload", "background", task("preload")),
queue.schedule("opened", "user", task("opened")),
]
await Promise.resolve()
expect(started).toEqual(["first", "second"])
first.resolve()
await running[0]
await Promise.resolve()
expect(started).toEqual(["first", "second", "opened"])
second.resolve()
await Promise.all(running)
expect(started).toEqual(["first", "second", "opened", "preload"])
expect(maximum).toBe(2)
})
test("clamps bridged tree wheel scrolling", () => {
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
@@ -138,6 +138,79 @@ export function activeTreeNavigation(request: number, current: number) {
return request === current
}
export function createPriorityTaskQueue<T>(concurrency: number) {
type Job = {
key: string
priority: "user" | "background"
promise: Promise<T>
run: () => void
}
const jobs = new Map<string, Job>()
const user: Job[] = []
const background: Job[] = []
let active = 0
const drain = () => {
while (active < concurrency) {
const job = user.pop() ?? background.shift()
if (!job) return
active++
job.run()
}
}
const schedule = (key: string, priority: Job["priority"], task: () => Promise<T>) => {
const existing = jobs.get(key)
if (existing) {
if (priority === "user") promote(key)
return existing.promise
}
const deferred = Promise.withResolvers<T>()
const job: Job = {
key,
priority,
promise: deferred.promise,
run: () => {
const complete = () => {
active--
jobs.delete(key)
drain()
}
Promise.resolve()
.then(task)
.then(
(value) => {
complete()
deferred.resolve(value)
},
(error) => {
complete()
deferred.reject(error)
},
)
},
}
jobs.set(key, job)
;(priority === "user" ? user : background).push(job)
drain()
return job.promise
}
const promote = (key: string) => {
const job = jobs.get(key)
if (!job || job.priority === "user") return
const index = background.indexOf(job)
if (index === -1) return
background.splice(index, 1)
job.priority = "user"
user.push(job)
}
return { schedule, promote }
}
export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) {
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
}
+1 -1
View File
@@ -11,7 +11,7 @@ export function HelpButton() {
return (
<Show when={!state.dismissed}>
<div class="fixed bottom-4 right-4 z-50">
<div class="fixed bottom-4 right-4 z-50 hidden md:block">
<Popover
open={shown()}
onOpenChange={setShown}
+1 -1
View File
@@ -1379,7 +1379,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
const [promptReady] = createResource(
() => prompt.ready().promise,
() => prompt.ready.promise,
(p) => p,
)
@@ -27,7 +27,7 @@ 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) }),
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
current: () => promptValue,
cursor: () => 0,
dirty: () => true,
@@ -388,12 +388,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId
if (draftID)
tabs.promoteDraft(draftID, {
server: server.key,
dirBase64: base64Encode(sessionDirectory),
sessionId: session.id,
})
if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
}
@@ -10,6 +10,7 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/core/util/path"
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
@@ -158,6 +159,7 @@ export function SessionHeader() {
const isV2 = settings.general.newLayoutDesigns
const search = settings.visibility.search
const status = settings.visibility.status
const isDesktop = createMediaQuery("(min-width: 768px)")
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
@@ -236,6 +238,7 @@ export function SessionHeader() {
statusLabel: language.t("status.popover.trigger"),
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: command.keybind("review.toggle"),
reviewVisible: isDesktop(),
reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(),
}))
@@ -518,6 +521,7 @@ type SessionHeaderV2ActionsState = {
statusLabel: string
reviewLabel: string
reviewKeybind: string
reviewVisible: boolean
reviewOpened: boolean
onReviewToggle: () => void
}
@@ -530,20 +534,22 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
<StatusPopoverV2 />
</Tooltip>
</Show>
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
state={props.state.reviewOpened ? "pressed" : undefined}
onClick={props.state.onReviewToggle}
aria-label={props.state.reviewLabel}
aria-expanded={props.state.reviewOpened}
aria-controls="review-panel"
icon={<IconV2 name="sidebar-right" />}
/>
</TooltipKeybind>
<Show when={props.state.reviewVisible}>
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
state={props.state.reviewOpened ? "pressed" : undefined}
onClick={props.state.onReviewToggle}
aria-label={props.state.reviewLabel}
aria-expanded={props.state.reviewOpened}
aria-controls="review-panel"
icon={<IconV2 name="sidebar-right" />}
/>
</TooltipKeybind>
</Show>
</div>
)
}
@@ -1,4 +1,5 @@
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
@@ -89,6 +90,7 @@ export const SettingsGeneralV2: Component = () => {
const dialog = useDialog()
const params = useParams()
const settings = useSettings()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
@@ -345,6 +347,20 @@ export const SettingsGeneralV2: Component = () => {
/>
</div>
</SettingsRowV2>
<Show when={mobile()}>
<SettingsRowV2
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
description={language.t("settings.general.row.mobileTitlebarBottom.description")}
>
<div data-action="settings-mobile-titlebar-bottom">
<Switch
checked={settings.general.mobileTitlebarPosition() === "bottom"}
onChange={(checked) => settings.general.setMobileTitlebarPosition(checked ? "bottom" : "top")}
/>
</div>
</SettingsRowV2>
</Show>
</SettingsListV2>
</div>
)
@@ -1,4 +1,3 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
@@ -14,7 +13,7 @@ import { ServerConnection, serverName } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server"
import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list"
import { isWslServer, useFilteredWslServers, WslAddServerButton, WslServerSettings } from "@/wsl/settings"
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
import "./settings-v2.css"
export const SettingsServersV2: Component = () => {
@@ -55,10 +54,7 @@ export const SettingsServersV2: Component = () => {
>
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
<ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
{language.t("dialog.server.add.button")}
</ButtonV2>
<WslAddServerButton />
<AddServerMenu onAddServer={openAdd} />
</div>
<Show when={showSearch()}>
<div class="settings-v2-tab-search">
@@ -167,6 +167,15 @@
background-color: var(--v2-background-bg-layer-01);
}
@media (max-width: 639px) {
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
[data-slot="tabs-v2-list"] {
width: 144px;
min-width: 144px;
padding-inline: 8px;
}
}
.settings-v2-nav-footer {
display: flex;
flex-direction: column;
+147 -110
View File
@@ -22,26 +22,27 @@ 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 { LayoutRoute, useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useServerSync } from "@/context/server-sync"
import { base64Encode } from "@opencode-ai/core/util/encode"
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 { projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMediaQuery } from "@solid-primitives/media"
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 { tabHref, useTabs } from "@/context/tabs"
import "./titlebar.css"
import { useServerSDK } from "@/context/server-sdk"
import { Session } from "@opencode-ai/sdk/v2"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createTabPromptState } from "@/context/prompt"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@@ -87,6 +88,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const location = useLocation()
const params = useParams()
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
const mobile = createMediaQuery("(max-width: 767px)")
const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom")
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
@@ -98,6 +101,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
const minHeight = () => {
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
if (useV2Titlebar() && mobile()) {
const inset = bottom() ? "env(safe-area-inset-bottom, 0px)" : "env(safe-area-inset-top, 0px)"
return `calc(${height}px + ${inset})`
}
if (mac()) return `${height / zoom()}px`
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
return undefined
@@ -235,10 +242,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
"shrink-0 relative flex flex-row": true,
"h-9 bg-v2-background-bg-deep overflow-visible": useV2Titlebar(),
"h-10 bg-background-base overflow-hidden": !useV2Titlebar(),
"order-last": bottom(),
}}
style={{
"min-height": minHeight(),
"padding-left": mac() ? `${84 / zoom()}px` : 0,
"padding-top": useV2Titlebar() && mobile() && !bottom() ? "env(safe-area-inset-top, 0px)" : undefined,
"padding-bottom": bottom() ? "env(safe-area-inset-bottom, 0px)" : undefined,
"padding-left": mac() && !mobile() ? `${84 / zoom()}px` : 0,
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": electronWindows()
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
@@ -252,22 +262,25 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<Switch>
<Match when={useV2Titlebar()}>
{(_) => {
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const navigate = useNavigate()
const layout = useLayout()
const newSessionHref = () => {
if (params.dir) return `/${params.dir}/session`
const project = layout.projects.list()[0]
if (!project) return "/"
return `/${base64Encode(project.worktree)}/session`
}
const global = useGlobal()
const tabs = useTabs()
const tabsStore = tabs.store
const tabsStoreActions = tabs
const [session] = createResource(
() => {
const route = layout.route()
return route.type === "session" ? route : undefined
},
(route) =>
serverSdk()
.client.session.get({ sessionID: route.sessionId })
.then((x) => x.data)
.catch(() => {}),
)
const matchRoute = (route: LayoutRoute) => {
if (route.type === "home") return
@@ -280,10 +293,9 @@ 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 session = sync.session.get(route.sessionId)
if (session?.parentID) {
const parentID = session.parentID
const s = session()
if (s?.parentID) {
const parentID = s.parentID
const parent = tabsStore.find(
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
)
@@ -304,15 +316,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}
if (route.type === "session") {
const sync = serverSync().createDirSyncContext(route.dir)
const session = sync.session.get(route.sessionId)
if (!session) return
const sessionId = session.parentID ?? session.id
const next = {
server: route.server ?? server.key,
dirBase64: route.dirBase64,
sessionId,
}
const s = session()
if (!s) return
const sessionId = s.parentID ?? s.id
const next = { server: route.server ?? server.key, sessionId }
tabsStoreActions.addSessionTab(next)
}
})
@@ -323,7 +330,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
tabsStoreActions.removeSessions(detail)
})
const openNewTab = () => navigate(newSessionHref())
const openNewTab = () => {
const route = layout.route()
const activeSession = session()
if (route.type === "session" && activeSession) {
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
return
}
const current = layout.projects.list()[0]
if (current) {
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
return
}
const fallback = global.servers.list().flatMap((conn) => {
const project = global.createServerCtx(conn).projects.list()[0]
return project ? [{ server: ServerConnection.key(conn), project }] : []
})[0]
if (!fallback) return
tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "")
}
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
command.register("titlebar-home", () => [
@@ -393,22 +421,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
if (next) tabs.select(next)
},
},
...Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
return {
id: `tab.${number}`,
category: "tab",
title: "",
keybind: `mod+${number}`,
disabled: layout.projects.list().length <= index,
hidden: true,
onSelect: () => {
const tab = tabsStore[index]
if (tab) tabs.select(tab)
},
}
}),
].filter((v) => v !== undefined)
})
@@ -421,10 +433,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return (
<div
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 pr-3 pt-2"
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3"
classList={{
"pl-2": mac(),
"pl-4": !mac(),
"pt-2": !bottom(),
"pb-2": bottom(),
"md:pl-2": mac(),
"md:pl-4": !mac(),
}}
>
<ChannelIndicator />
@@ -470,6 +484,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<For each={tabsStore}>
{(tab, i) => {
let ref!: HTMLDivElement
useTabShortcut(i, () => tabs.select(tab))
const divider = () =>
i() !== 0 && (
@@ -495,25 +510,61 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
)
}
const sdk = createMemo(() => {
const conn = server.list.find((s) => ServerConnection.key(s) === tab.server)
if (!conn) return null
const { sdk } = global.createServerCtx(conn)
return sdk
})
const [session] = createResource(
() => {
const id = tab.sessionId
const _sdk = sdk()
if (!_sdk) return null
return { id, sdk: _sdk }
},
({ id, sdk }) =>
sdk.client.session
.get({ sessionID: id })
.then((x) => x.data)
.catch(() => undefined),
)
createEffect(() => {
if (tab.type !== "session") return
const _sdk = sdk()
if (!_sdk) return
const sess = session()
if (!sess) return
createTabPromptState(tabs, tab, _sdk.scope, {
dir: base64Encode(sess.directory),
id: sess.id,
})
})
return (
<>
{divider()}
<TabNavItem
ref={ref}
href={tabHref(tab)}
server={tab.server}
directory={decode64(tab.dirBase64)!}
sessionId={tab.sessionId}
onNavigate={() => {
tabs.select(tab)
<Show when={session()}>
{(session) => (
<TabNavItem
ref={ref}
href={tabHref(tab)}
server={tab.server}
sessionId={tab.sessionId}
session={session()}
onNavigate={() => {
tabs.select(tab)
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
/>
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
/>
)}
</Show>
</>
)
}}
@@ -563,8 +614,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
as="a"
href={newSessionHref()}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</Show>
@@ -789,11 +839,30 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
)
}
function useTabShortcut(index: () => number, onSelect: () => void) {
const command = useCommand()
command.register(() => {
const number = index() + 1
if (number > 9) return []
return [
{
id: `tab.${number}`,
category: "tab",
title: "",
keybind: `mod+${number}`,
hidden: true,
onSelect,
},
]
})
}
function TabNavItem(props: {
ref?: HTMLDivElement
href: string
server: ServerConnection.Key
directory: string
sessionId?: string
hideClose?: boolean
onClose: () => void
@@ -801,43 +870,31 @@ function TabNavItem(props: {
active?: boolean
activeServer: boolean
forceTruncate?: boolean
session: Session
}) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
const global = useGlobal()
const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.createServerCtx(conn)
})
const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory))
const [session] = createResource(
() => {
const ctx = dirSyncCtx()
if (!ctx || !props.sessionId) return
return [props.sessionId, ctx] as const
},
async ([sessionId, dirSyncCtx]) => {
await dirSyncCtx.session.sync(sessionId).catch(() => {})
return dirSyncCtx.session.get(sessionId)
},
{ initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined },
)
return (
<div
ref={props.ref}
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<Show when={session.latest}>
<Show when={props.session}>
{(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
@@ -851,9 +908,9 @@ function TabNavItem(props: {
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
>
<span data-slot="project-avatar-slot">
<ProjectTabAvatar
<SessionTabAvatar
project={project()}
directory={props.directory}
directory={session().directory}
sessionId={session().id}
activeServer={props.activeServer}
/>
@@ -887,26 +944,6 @@ function TabNavItem(props: {
)
}
function ProjectTabAvatar(props: {
project?: LocalProject
directory: string
sessionId: string
activeServer: boolean
}) {
const directory = () => props.directory
const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
return (
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
loading={state.loading()}
/>
)
}
function DraftTabItem(props: {
ref?: HTMLDivElement
href: string
@@ -924,7 +961,7 @@ function DraftTabItem(props: {
<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)]"
class="group relative flex h-7 w-56 shrink-0 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)
@@ -969,7 +1006,7 @@ function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: s
return (
<div
ref={props.ref}
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap 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)
+4 -1
View File
@@ -2,12 +2,14 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
import { createScopedCache } from "@/utils/scoped-cache"
import { uuid } from "@/utils/uuid"
import type { SelectedLineRange } from "@/context/file"
import { useSDK } from "./sdk"
export type LineComment = {
id: string
@@ -202,6 +204,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
gate: false,
init: () => {
const params = useParams()
const sdk = useSDK()
const serverSDK = useServerSDK()
const cache = createScopedCache(
(key) => {
@@ -228,7 +231,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
return cache.get(key).value
}
const session = createMemo(() => load(params.dir!, params.id))
const session = createMemo(() => load(base64Encode(sdk().directory), params.id))
return {
ready: () => session().ready(),
+2 -1
View File
@@ -3,6 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
@@ -65,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
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(base64Encode(sdk().directory), params.id)),
)
const inflight = new Map<string, Promise<void>>()
+13 -3
View File
@@ -16,6 +16,7 @@ import { createPathHelpers } from "./file/path"
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
import { requireServerKey } from "@/utils/session-route"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -79,7 +80,7 @@ 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 }
| { type: "session"; sessionId: string; server?: ServerConnection.Key }
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
const all = current?.all ?? []
@@ -131,6 +132,14 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
return { type: "draft", draftID }
}
if (parts[0] === "server" && parts[2] === "session" && parts[3]) {
return {
type: "session",
sessionId: parts[3],
server: requireServerKey(parts[1]),
}
}
const dirBase64 = parts[0]
const dir = decode64(dirBase64)
if (!dir) return { type: "home" }
@@ -138,7 +147,7 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
if (parts[1] !== "session") return { type: "home" }
const id = parts[2]
if (id) return { type: "session", dir, dirBase64, sessionId: id }
if (id) return { type: "session", sessionId: id }
return { type: "dir-new-sesssion", dir, dirBase64 }
}
@@ -154,6 +163,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const route = createMemo(() => {
const value = currentRoute(location.pathname, location.search)
if (value.type === "home") return value
if (value.server) return value
return { ...value, server: server.key }
})
@@ -572,7 +582,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
handoff: {
tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) {
setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() })
setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() })
},
clearTabs() {
if (!store.handoff?.tabs) return
+4 -4
View File
@@ -1,5 +1,5 @@
import { createStore, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServerSDK } from "./server-sdk"
@@ -108,7 +108,7 @@ function buildNotificationIndex(list: Notification[]) {
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification",
gate: false,
init: () => {
init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
const params = useParams()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
@@ -119,10 +119,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const empty: Notification[] = []
const currentDirectory = createMemo(() => {
return decode64(params.dir)
return props.directory?.() ?? decode64(params.dir)
})
const currentSession = createMemo(() => params.id)
const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
const [store, setStore, _, ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
+4 -4
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup } from "solid-js"
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
@@ -47,13 +47,13 @@ function hasPermissionPromptRules(permission: unknown) {
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission",
gate: false,
init: () => {
init: (props: { directory?: Accessor<string | undefined> }) => {
const params = useParams()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const permissionsEnabled = createMemo(() => {
const directory = decode64(params.dir)
const directory = props.directory?.() ?? decode64(params.dir)
if (!directory) return false
const [store] = serverSync().child(directory)
return hasPermissionPromptRules(store.config.permission)
@@ -85,7 +85,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
// When config has permission: "allow", auto-enable directory-level auto-accept
createEffect(() => {
if (!ready()) return
const directory = decode64(params.dir)
const directory = props.directory?.() ?? decode64(params.dir)
if (!directory) return
const [childStore] = serverSync().child(directory)
const perm = childStore.config.permission
+47 -7
View File
@@ -1,12 +1,17 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { checksum } from "@opencode-ai/core/util/encode"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useParams, useSearchParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs"
import { useServer } from "./server"
import { requireServerKey } from "@/utils/session-route"
import { useSettings } from "./settings"
interface PartBase {
content: string
@@ -180,7 +185,7 @@ function promptTarget(serverScope: ServerScope, scope: Scope) {
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
}
function createPromptSession(serverScope: ServerScope, scope: Scope) {
export function createPromptSession(serverScope: ServerScope, scope: Scope) {
const [store, setStore, _, ready] = persisted(
promptTarget(serverScope, scope),
createStore<PromptStore>(promptStore()),
@@ -189,6 +194,12 @@ function createPromptSession(serverScope: ServerScope, scope: Scope) {
return { ready, ...createPromptStateValue(store, setStore) }
}
export function createPromptReady(session: Accessor<PromptSession>) {
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
}
function promptStore(): PromptStore {
return {
prompt: clonePrompt(DEFAULT_PROMPT),
@@ -246,18 +257,28 @@ export function createPromptState() {
const [store, setStore] = createStore<PromptStore>(promptStore())
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready: () => ready,
ready,
...createPromptStateValue(store, setStore),
}
}
export const createTabPromptState = (
tabs: ReturnType<typeof useTabs>,
tab: Tab,
...args: Parameters<typeof createPromptSession>
) => tabs.state(tab, "prompt", () => createPromptSession(...args))
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
name: "Prompt",
gate: false,
init: () => {
const params = useParams()
const params = useParams<{ serverKey?: string; id?: string }>()
const sdk = useSDK()
const [search] = useSearchParams<{ draftId?: string }>()
const serverSDK = useServerSDK()
const server = useServer()
const tabs = useTabs()
const settings = useSettings()
const cache = new Map<string, PromptCacheEntry>()
const disposeAll = () => {
@@ -280,7 +301,25 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const owner = getOwner()
const tab = createMemo<Tab | undefined>(() => {
if (!settings.general.newLayoutDesigns()) return
if (search.draftId) {
return tabs.store.find((item) => item.type === "draft" && item.draftID === search.draftId)
}
if (!params.id) return
const serverKey = params.serverKey ? requireServerKey(params.serverKey) : server.key
return (
tabs.store.find(
(item) => item.type === "session" && item.server === serverKey && item.sessionId === params.id,
) ?? { type: "session", server: serverKey, sessionId: params.id }
)
})
const load = (scope: Scope) => {
const current = tab()
if (current) {
return createTabPromptState(tabs, current, serverSDK().scope, scope)
}
const key = scopeKey(scope)
const existing = cache.get(key)
if (existing) {
@@ -303,12 +342,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const session = createMemo(() =>
load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }),
load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
)
const pick = (scope?: Scope) => (scope ? load(scope) : session())
const ready = createPromptReady(session)
return {
ready: () => session().ready,
ready,
current: () => session().current(),
cursor: () => session().cursor(),
dirty: () => session().dirty(),
+9
View File
@@ -33,6 +33,7 @@ export interface Settings {
editToolPartsExpanded: boolean
showSessionProgressBar: boolean
showCustomAgents: boolean
mobileTitlebarPosition: "top" | "bottom"
newLayoutDesigns?: boolean
}
appearance: {
@@ -118,6 +119,7 @@ const defaultSettings: Settings = {
editToolPartsExpanded: false,
showSessionProgressBar: true,
showCustomAgents: false,
mobileTitlebarPosition: "top",
},
appearance: {
fontSize: 14,
@@ -248,6 +250,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowCustomAgents(value: boolean) {
setStore("general", "showCustomAgents", value)
},
mobileTitlebarPosition: withFallback(
() => store.general?.mobileTitlebarPosition,
defaultSettings.general.mobileTitlebarPosition,
),
setMobileTitlebarPosition(value: "top" | "bottom") {
setStore("general", "mobileTitlebarPosition", value)
},
newLayoutDesigns,
setNewLayoutDesigns(value: boolean) {
setStore("general", "newLayoutDesigns", value)
+33
View File
@@ -0,0 +1,33 @@
import { createRoot, type Owner } from "solid-js"
type Entry = {
value: unknown
dispose: VoidFunction
}
export function createTabMemory(owner: Owner | null) {
const entries = new Map<string, Map<string, Entry>>()
const remove = (key: string) => {
const state = entries.get(key)
if (!state) return
for (const entry of state.values()) entry.dispose()
entries.delete(key)
}
return {
ensure<T>(key: string, name: string, init: () => T) {
const state = entries.get(key) ?? new Map<string, Entry>()
if (!entries.has(key)) entries.set(key, state)
const existing = state.get(name)
if (existing) return existing.value as T
const entry = createRoot((dispose) => ({ value: init(), dispose }), owner)
state.set(name, entry)
return entry.value
},
remove,
dispose() {
for (const key of entries.keys()) remove(key)
},
}
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./tab-memory"
describe("tab memory", () => {
test("keeps state until its tab is removed", () => {
createRoot((dispose) => {
const memory = createTabMemory(getOwner())
let disposed = 0
const first = memory.ensure("tab", "prompt", () => {
onCleanup(() => disposed++)
return { value: "prompt" }
})
expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
memory.remove("tab")
expect(disposed).toBe(1)
expect(memory.ensure("tab", "prompt", () => ({ value: "new" }))).not.toBe(first)
dispose()
})
})
})
+23 -26
View File
@@ -1,19 +1,19 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode"
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 { createEffect, getOwner, onCleanup, startTransition } from "solid-js"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { usePlatform } from "./platform"
import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route"
import { createTabMemory } from "./tab-memory"
export type SessionTab = {
type: "session"
server: ServerConnection.Key
dirBase64: string
sessionId: string
}
@@ -34,16 +34,12 @@ type RecentTab = {
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
export const tabHref = (tab: Tab) =>
tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}`
tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.sessionId)
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
const dirBase64 = base64Encode(session.directory)
return tabs.some(
(tab) =>
tab.type === "session" && tab.server === server && tab.dirBase64 === dirBase64 && tab.sessionId === session.id,
)
return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
}
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
@@ -71,6 +67,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const params = useParams()
const navigate = useNavigate()
const location = useLocation()
const memory = createTabMemory(getOwner())
const closing = new Set<string>()
let recentWrite = 0
@@ -94,25 +91,25 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
}
onCleanup(memory.dispose)
createEffect(() => {
if (!ready() || !recentReady()) return
const servers = new Set(server.list.map(ServerConnection.key))
const next = store.filter((tab) => servers.has(tab.server))
if (next.length !== store.length) setStore(() => next)
if (next.length !== store.length) {
for (const tab of store) {
if (!servers.has(tab.server)) memory.remove(tabKey(tab))
}
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
}
void startTransition(() => {
server.setActive(tab.server)
navigate(href)
})
navigate(href)
}
const actions = {
@@ -163,6 +160,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next))
if (active) navigateTab(next)
})
memory.remove(`draft:${draftID}`)
removeDraftPersisted(draftID)
},
removeTab: (index: number) => {
@@ -182,12 +180,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (nextTab) navigateTab(nextTab)
else navigate("/")
}).finally(() => closing.delete(key))
memory.remove(key)
if (draftID) removeDraftPersisted(draftID)
},
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))
for (const key of removed) memory.remove(key)
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
for (const draftID of drafts) removeDraftPersisted(draftID)
if (server.key === key) navigate("/")
@@ -195,11 +195,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
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),
(tab) => tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId),
)
.map(tabKey)
void startTransition(() => {
@@ -211,7 +207,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
? tabHref({
type: "session",
server: server.key,
dirBase64: params.dir,
sessionId: params.id,
})
: undefined
@@ -224,14 +219,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const removedCurrent =
currentTab?.type === "session" &&
currentTab.server === server.key &&
atob(currentTab.dirBase64) === input.directory &&
sessionIDs.has(currentTab.sessionId)
for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i]
if (!tab || tab.type !== "session") continue
if (tab.server !== server.key) continue
if (atob(tab.dirBase64) !== input.directory) continue
if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1)
}
@@ -246,6 +239,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
})
for (const key of removed) memory.remove(key)
},
select: navigateTab,
remember(tab: Tab) {
@@ -265,6 +259,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}
navigate("/")
},
state<T>(tab: Tab, name: string, init: () => T) {
return memory.ensure(tabKey(tab), name, init)
},
}
return { ...actions, store, ready, recentReady }
+7 -5
View File
@@ -4,7 +4,8 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
import { useParams } from "@solidjs/router"
import { useSDK, type DirectorySDK } from "./sdk"
import type { Platform } from "./platform"
import { useServer } from "./server"
import { useServerSDK } from "./server-sdk"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
@@ -374,10 +375,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
gate: false,
init: () => {
const sdk = useSDK()
const server = useServer()
const serverSDK = useServerSDK()
const params = useParams()
const cache = new Map<string, TerminalCacheEntry>()
const scope = server.scope()
const scope = () => serverSDK().scope
const directory = createMemo(() => base64Encode(sdk().directory))
caches.add(cache)
onCleanup(() => caches.delete(cache))
@@ -421,11 +423,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope))
const workspace = createMemo(() => loadWorkspace(directory(), params.id, scope()))
createEffect(
on(
() => ({ dir: params.dir, id: params.id, scope }),
() => ({ dir: directory(), id: params.id, scope: scope() }),
(next, prev) => {
if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return
+5
View File
@@ -589,6 +589,8 @@ export const dict = {
"home.title": "Home",
"home.projects": "Projects",
"home.project.add": "Add project",
"home.server.collapse": "Collapse server projects",
"home.server.expand": "Expand server projects",
"home.sessions.search.placeholder": "Search sessions",
"home.sessions.search.sessions": "Sessions",
"home.sessions.search.noResults": "No sessions found for {{query}}",
@@ -839,6 +841,9 @@ export const dict = {
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
"settings.general.row.mobileTitlebarBottom.description":
"Place the title bar and session tabs at the bottom of the screen on mobile",
"settings.general.row.showCustomAgents.title": "Custom agents",
"settings.general.row.showCustomAgents.description": "Show the agent picker in the composer",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
+22 -8
View File
@@ -2,26 +2,40 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js"
import { type Accessor, createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64"
import { Schema } from "effect"
import type { ServerConnection } from "@/context/server"
import { sessionHref } from "@/utils/session-route"
export function DirectoryDataProvider(props: ParentProps<{ directory: string; draftID?: string }>) {
export function DirectoryDataProvider(
props: ParentProps<{
directory: string | Accessor<string>
draftID?: string
server?: Accessor<ServerConnection.Key | undefined>
}>,
) {
const location = useLocation()
const navigate = useNavigate()
const params = useParams()
const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory))
const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
const slug = createMemo(() => base64Encode(directory()))
const href = (sessionID: string) => {
const server = props.server?.()
if (server) return sessionHref(server, sessionID)
return `/${slug()}/session/${sessionID}`
}
createEffect(() => {
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
if (props.draftID) return
if (props.draftID || props.server?.()) return
const next = sync().data.path.directory
if (!next || next === props.directory) return
if (!next || next === directory()) return
const path = location.pathname.slice(slug().length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
@@ -37,9 +51,9 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr
return (
<DataProvider
data={sync().data}
directory={props.directory}
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
directory={directory()}
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
onSessionHref={href}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
+94 -56
View File
@@ -38,15 +38,15 @@ import {
sortedRootSessions,
toggleHomeProjectSelection,
} from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { sessionTitle } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key"
import { useGlobal } from "@/context/global"
import { useCommand } from "@/context/command"
import { useSettings } from "@/context/settings"
import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health"
import { Persist, persisted } from "@/utils/persist"
const HOME_SESSION_LIMIT = 64
const HOME_ROW_LAYOUT =
@@ -113,16 +113,7 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}`
}
export default function Home() {
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
<HomeDesign />
</Show>
)
}
function HomeDesign() {
export function NewHome() {
const sync = useServerSync()
const layout = useLayout()
const platform = usePlatform()
@@ -313,7 +304,7 @@ function HomeDesign() {
const ctx = global.createServerCtx(conn)
ctx.projects.open(directory)
ctx.projects.touch(directory)
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`)
navigateOnServer(conn, `/server/${base64Encode(ServerConnection.key(conn))}/session/${session.id}`)
}
function chooseProject(conn: ServerConnection.Any) {
@@ -339,7 +330,7 @@ function HomeDesign() {
return (
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]">
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 pb-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 lg:pb-16">
<HomeProjectColumn
projects={projects()}
selected={state.selection}
@@ -365,7 +356,7 @@ function HomeDesign() {
/>
<section
class="min-h-0 min-w-0 flex-1 flex flex-col pt-12"
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12"
aria-label={language.t("sidebar.project.recentSessions")}
>
<HomeSessionSearch
@@ -416,7 +407,7 @@ function HomeDesign() {
record={record}
server={state.selection.server}
activeServer={state.selection.server === server.key}
openSession={openSession}
onClick={() => openSession(record.session)}
/>
)}
</For>
@@ -429,6 +420,12 @@ function HomeDesign() {
</div>
</ScrollView>
</section>
<HomeUtilityNav
class="flex lg:hidden"
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
</div>
</div>
)
@@ -452,8 +449,15 @@ function HomeProjectColumn(props: {
const global = useGlobal()
const dialog = useDialog()
const controller = useServerManagementController({ navigateOnAdd: false })
const [state, setState] = persisted(
Persist.global("home.servers", ["home.servers.v1"]),
createStore({ collapsed: {} as Record<string, boolean> }),
)
return (
<aside class="flex min-w-0 flex-col lg:pt-[52px] mt-14 gap-4" aria-label={props.language.t("home.projects")}>
<aside
class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
aria-label={props.language.t("home.projects")}
>
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<Show when={global.servers.list().length === 1}>
@@ -477,20 +481,23 @@ function HomeProjectColumn(props: {
const key = ServerConnection.key(item)
const healthy = () => !!global.servers.health[key]?.healthy
const serverCtx = global.createServerCtx(item)
const collapsed = () => !!state.collapsed[key]
return (
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<HomeServerRow
server={item}
selected={props.selected.server === key && !props.selected.directory}
healthy={healthy()}
collapsed={collapsed()}
health={global.servers.health[key]}
controller={controller}
focusServer={props.focusServer}
chooseProject={props.chooseProject}
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
toggleCollapsed={() => setState("collapsed", key, !state.collapsed[key])}
language={props.language}
/>
<Show when={healthy()}>
<Show when={healthy() && !collapsed()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} />
</Show>
@@ -499,37 +506,55 @@ function HomeProjectColumn(props: {
}}
</For>
</Show>
<div class="mt-4 flex min-w-0 flex-col gap-1">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.openSettings}
>
<IconV2 name="settings-gear" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
</button>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.openHelp}
>
<IconV2 name="help" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
</button>
</div>
<HomeUtilityNav
class="mt-4 hidden lg:flex"
openSettings={props.openSettings}
openHelp={props.openHelp}
language={props.language}
/>
</aside>
)
}
function HomeUtilityNav(props: {
class?: string
openSettings: () => void
openHelp: () => void
language: ReturnType<typeof useLanguage>
}) {
return (
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1`}>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.openSettings}
>
<IconV2 name="settings-gear" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
</button>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.openHelp}
>
<IconV2 name="help" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
</button>
</div>
)
}
function HomeServerRow(props: {
server: ServerConnection.Any
selected: boolean
healthy: boolean
collapsed: boolean
health: ServerHealth | undefined
controller: ReturnType<typeof useServerManagementController>
focusServer: (server: ServerConnection.Any) => void
chooseProject: (server: ServerConnection.Any) => void
openEdit: (server: ServerConnection.Http) => void
toggleCollapsed: () => void
language: ReturnType<typeof useLanguage>
}) {
const [state, setState] = createStore({ menuOpen: false })
@@ -542,7 +567,30 @@ function HomeServerRow(props: {
disabled={!props.healthy}
onClick={() => props.focusServer(props.server)}
>
<div class="flex size-4 shrink-0 items-center justify-center">
<Show when={props.healthy}>
<span
data-action="home-server-collapse"
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
aria-label={
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
}
aria-expanded={!props.collapsed}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
props.toggleCollapsed()
}}
onPointerDown={(event) => event.preventDefault()}
>
<IconV2
name="chevron-down"
size="small"
class="transition-transform duration-150 ease-in-out"
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
/>
</span>
</Show>
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
<ServerHealthIndicator health={props.health} />
</div>
<span class="flex min-w-0 items-center gap-1">
@@ -707,21 +755,6 @@ function HomeProjectAvatar(props: { project: LocalProject }) {
)
}
function HomeSessionAvatar(props: { project: LocalProject; session: Session; activeServer: boolean }) {
const directory = () => props.session.directory
const sessionId = () => props.session.id
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
return (
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
unread={state.unread()}
loading={state.loading()}
/>
)
}
function HomeSessionLeading(props: {
project: LocalProject
session: Session
@@ -739,7 +772,12 @@ function HomeSessionLeading(props: {
style={{ right: "calc(100% + 12px)" }}
/>
</Show>
<HomeSessionAvatar project={props.project} session={props.session} activeServer={props.activeServer} />
<SessionTabAvatar
project={props.project}
directory={props.session.directory}
sessionId={props.session.id}
activeServer={props.activeServer}
/>
</div>
)
}
@@ -1024,7 +1062,7 @@ function HomeSessionRow(props: {
record: HomeSessionRecord
server: ServerConnection.Key
activeServer: boolean
openSession: (session: Session) => void
onClick: () => void
}) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
@@ -1033,7 +1071,7 @@ function HomeSessionRow(props: {
type="button"
data-component="home-session-row"
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
onClick={() => props.openSession(props.record.session)}
onClick={props.onClick}
>
<HomeSessionLeading
project={props.record.project}
@@ -1093,7 +1131,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
].filter((group) => group.sessions.length > 0)
}
function LegacyHome() {
export function LegacyHome() {
const sync = useServerSync()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
+38
View File
@@ -0,0 +1,38 @@
import { createEffect, Suspense, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast"
export default function NewLayout(props: ParentProps) {
const platform = usePlatform()
const navigate = useNavigate()
setNavigate(navigate)
createEffect(() => setV2Toast(true))
const update: TitlebarUpdate = {
version: () => {
const state = platform.updater?.state()
if (state?.status !== "ready") return
return state.version
},
installing: () => platform.updater?.state().status === "installing",
install: () => void platform.updater?.install(),
}
return (
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Titlebar update={update} />
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Suspense>{props.children}</Suspense>
</main>
{import.meta.env.DEV && <DebugBar />}
<HelpButton />
<ToastRegion v2 />
</div>
)
}
+149 -170
View File
@@ -13,7 +13,7 @@ import {
type Accessor,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout"
import { useServerSync } from "@/context/server-sync"
import { Persist, persisted } from "@/utils/persist"
@@ -92,7 +92,7 @@ import {
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
export default function Layout(props: ParentProps) {
export default function LegacyLayout(props: ParentProps) {
const serverSDK = useServerSDK()
const [store, setStore, , ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
@@ -131,10 +131,8 @@ export default function Layout(props: ParentProps) {
const command = useCommand()
const theme = useTheme()
const language = useLanguage()
const newDesign = createMemo(() => settings.general.newLayoutDesigns())
createEffect(() => setV2Toast(newDesign()))
createEffect(() => setV2Toast(false))
const initialDirectory = decode64(params.dir)
const location = useLocation()
const route = createMemo(() => {
const slug = params.dir
if (!slug) return { slug, dir: "" }
@@ -158,7 +156,7 @@ export default function Layout(props: ParentProps) {
const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({
autoselect: !initialDirectory && !newDesign(),
autoselect: !initialDirectory,
busyWorkspaces: {} as Record<string, boolean>,
hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined,
@@ -996,7 +994,7 @@ export default function Layout(props: ParentProps) {
id: "sidebar.toggle",
title: language.t("command.sidebar.toggle"),
category: language.t("command.category.view"),
keybind: newDesign() ? undefined : "mod+b",
keybind: "mod+b",
onSelect: () => layout.sidebar.toggle(),
},
{
@@ -1134,20 +1132,19 @@ export default function Layout(props: ParentProps) {
},
]
if (!newDesign())
Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
commands.push({
id: `project.${number}`,
category: language.t("command.category.project"),
title: `Open Project {number}`,
keybind: `mod+${number}`,
disabled: layout.projects.list().length <= index,
hidden: true,
onSelect: () => navigateToProjectIndex(index),
})
Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
commands.push({
id: `project.${number}`,
category: language.t("command.category.project"),
title: `Open Project {number}`,
keybind: `mod+${number}`,
disabled: layout.projects.list().length <= index,
hidden: true,
onSelect: () => navigateToProjectIndex(index),
})
})
for (const [id] of availableThemeEntries()) {
commands.push({
@@ -1812,7 +1809,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
document.documentElement.style.setProperty(
"--dialog-left-margin",
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
`${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
)
})
@@ -2355,176 +2352,158 @@ export default function Layout(props: ParentProps) {
)
return (
<Show
when={!newDesign()}
fallback={
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
<HelpButton />
<ToastRegion v2={newDesign()} />
</div>
}
>
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<Show when={updateVersion() !== undefined}>
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
</Show>
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
<div class="size-full relative overflow-x-hidden">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<Show when={updateVersion() !== undefined}>
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
</Show>
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
<div class="size-full relative overflow-x-hidden">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
arm()
}}
>
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
</nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
arm()
}}
>
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
</nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div
classList={{
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
</nav>
</div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div
classList={{
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
</div>
<div
/>
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
</nav>
</div>
<div
classList={{
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}}
>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</div>
</main>
</div>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div>
<div
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
</Show>
</div>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div>
</div>
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
</div>
<HelpButton />
<ToastRegion v2={newDesign()} />
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
</div>
</Show>
<HelpButton />
<ToastRegion v2={false} />
</div>
)
}
@@ -0,0 +1,33 @@
import type { LocalProject } from "@/context/layout"
import { getProjectAvatarVariant } from "@/context/layout"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { SessionProgressIndicatorV2 } from "@opencode-ai/ui/v2/session-progress-indicator-v2"
import { Show } from "solid-js"
export function SessionTabAvatar(props: {
project?: LocalProject
directory: string
sessionId: string
activeServer: boolean
}) {
const directory = () => props.directory
const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
return (
<Show
when={state.loading()}
fallback={
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
/>
}
>
<SessionProgressIndicatorV2 class="shrink-0" />
</Show>
)
}
+53 -35
View File
@@ -28,7 +28,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast"
import { checksum } from "@opencode-ai/core/util/encode"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
@@ -56,7 +56,6 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
import { createTimelineModel } from "@/pages/session/timeline/model"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout"
import { useServer } from "@/context/server"
import { syncSessionModel } from "@/pages/session/session-model-helpers"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
@@ -92,7 +91,6 @@ export default function Page() {
const prompt = usePrompt()
const comments = useComments()
const terminal = useTerminal()
const server = useServer()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
@@ -137,11 +135,11 @@ export default function Page() {
layout.handoff.clearTabs()
return
}
if (pending.scope !== server.scope()) return
if (pending.scope !== serverSDK().scope) return
if (pending.id !== id) return
layout.handoff.clearTabs()
if (pending.dir !== (params.dir ?? "")) return
if (pending.dir !== base64Encode(sdk().directory)) return
const from = workspaceTabs().tabs()
if (from.all.length === 0 && !from.active) return
@@ -247,7 +245,7 @@ export default function Page() {
createEffect(
on(
() => ({ dir: params.dir, id: params.id }),
() => ({ dir: sdk().directory, id: params.id }),
(next, prev) => {
if (!prev) return
if (next.dir === prev.dir && next.id === prev.id) return
@@ -575,7 +573,7 @@ export default function Page() {
createEffect(
on(
() => params.dir,
() => sdk().directory,
(dir) => {
if (!dir) return
setStore("newSessionWorktree", "main")
@@ -1570,40 +1568,56 @@ export default function Page() {
/>
)
const mobileTabs = (compact = false, bottom = false) => (
<Tabs value={store.mobileTab} class="h-auto">
<Tabs.List
classList={{
"!h-9": compact,
"[&::after]:!border-b-0 [&::after]:!border-t [&::after]:!border-border-weak-base": bottom,
}}
>
<Tabs.Trigger
value="session"
classList={{
"!w-1/2 !max-w-none": true,
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent": bottom,
}}
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
onClick={() => setStore("mobileTab", "session")}
>
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
classList={{
"!w-1/2 !max-w-none !border-r-0": true,
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent": bottom,
}}
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
onClick={() => setStore("mobileTab", "changes")}
>
{hasReview()
? language.t("session.review.filesChanged", { count: reviewCount() })
: language.t("session.review.change.other")}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
)
const mobileTabsBottom = createMemo(
() => !isDesktop() && settings.general.newLayoutDesigns() && settings.general.mobileTitlebarPosition() === "bottom",
)
return (
<div class="relative size-full overflow-hidden flex flex-col">
{sessionSync() ?? ""}
<SessionHeader />
<div
class="flex-1 min-h-0 flex flex-col md:flex-row "
class="flex-1 min-h-0 flex flex-col md:flex-row"
classList={{
"gap-2 p-2": settings.general.newLayoutDesigns(),
}}
>
<Show when={!isDesktop() && !!params.id}>
<Tabs value={store.mobileTab} class="h-auto">
<Tabs.List>
<Tabs.Trigger
value="session"
class="!w-1/2 !max-w-none"
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "session")}
>
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
class="!w-1/2 !max-w-none !border-r-0"
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "changes")}
>
{hasReview()
? language.t("session.review.filesChanged", { count: reviewCount() })
: language.t("session.review.change.other")}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
</Show>
<Show when={!isDesktop() && !!params.id && !settings.general.newLayoutDesigns()}>{mobileTabs()}</Show>
<div
classList={{
@@ -1622,6 +1636,9 @@ export default function Page() {
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}}
>
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
{mobileTabs(true)}
</Show>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id && mobileChanges()}>
@@ -1629,8 +1646,8 @@ export default function Page() {
{reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
header: "px-4 !h-16 !pb-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
@@ -1686,7 +1703,8 @@ export default function Page() {
</Switch>
</div>
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{composerRegion("dock")}</Show>
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
</div>
<Show when={desktopReviewOpen()}>
@@ -1,4 +1,4 @@
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate, useSearchParams } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
@@ -25,10 +25,12 @@ import { pathKey } from "@/utils/path-key"
import { useLocal } from "@/context/local"
import { useProviders } from "@/hooks/use-providers"
import { useSettings } from "@/context/settings"
import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { ServerConnection, useServer } from "@/context/server"
import { type DraftTab, useTabs } from "@/context/tabs"
import { useDirectoryPicker } from "@/components/directory-picker"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useGlobal } from "@/context/global"
export function SessionComposerRegion(props: {
state: SessionComposerState
@@ -72,26 +74,52 @@ export function SessionComposerRegion(props: {
const settings = useSettings()
const server = useServer()
const tabs = useTabs()
const global = useGlobal()
const pickDirectory = useDirectoryPicker()
const [search] = useSearchParams<{ draftId?: string }>()
const view = layout.view(route.sessionKey)
const draft = createMemo(() => {
if (!search.draftId) return
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
})
const projectServer = createMemo(() => {
if (!search.draftId) return server.current
const target = draft()?.server
if (!target) return
return server.list.find((conn) => ServerConnection.key(conn) === target)
})
const projectServerCtx = createMemo(() => {
const conn = projectServer()
if (conn) return global.createServerCtx(conn)
})
const projects = createMemo(() =>
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
)
const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
const selectProject = (worktree: string) => {
layout.projects.open(worktree)
server.projects.touch(worktree)
const conn = projectServer()
const target = projectServerCtx()
if (search.draftId) {
tabs.updateDraft(search.draftId, { server: server.key, directory: worktree })
if (!conn || !target) return
target.projects.open(worktree)
target.projects.touch(worktree)
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
return
}
layout.projects.open(worktree)
server.projects.touch(worktree)
navigate(`/${base64Encode(worktree)}/session`)
}
const addProject = (title: string) => {
if (!server.current) return
const conn = projectServer()
if (!conn) return
pickDirectory({
server: server.current,
server: conn,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
@@ -114,7 +142,7 @@ export function SessionComposerRegion(props: {
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
},
projects: {
available: layout.projects.list(),
available: projects(),
directory: sdk().directory,
select: selectProject,
add: addProject,
@@ -200,7 +228,11 @@ export function SessionComposerRegion(props: {
const openParent = () => {
const id = parentID()
if (!id) return
navigate(`/${route.params.dir}/session/${id}`)
navigate(
route.params.serverKey
? sessionHref(requireServerKey(route.params.serverKey), id)
: legacySessionHref(sdk().directory, id),
)
}
createEffect(() => {
@@ -211,6 +243,12 @@ export function SessionComposerRegion(props: {
update()
})
const ready = Promise.resolve()
const [promptReadyResource] = createResource(
() => prompt.ready.promise ?? ready,
(promise) => promise.then(() => true),
)
return (
<div
ref={props.setPromptDockRef}
@@ -253,7 +291,7 @@ export function SessionComposerRegion(props: {
<Show when={showComposer()}>
<Show
when={prompt.ready()}
when={promptReadyResource()}
fallback={
<>
<Show when={rolled()} keyed>
+2 -3
View File
@@ -405,7 +405,7 @@ export function FileTabContent(props: { tab: string }) {
cacheKey: cacheKey(),
}}
enableLineSelection
enableHoverUtility
enableGutterUtility
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
@@ -413,11 +413,10 @@ export function FileTabContent(props: { tab: string }) {
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
renderHoverUtility={commentsUi.renderHoverUtility}
renderGutterUtility={commentsUi.renderGutterUtility}
onLineSelected={(range: SelectedLineRange | null) => {
commentsUi.onLineSelected(range)
}}
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
commentsUi.onLineSelectionEnd(range)
}}
@@ -1,15 +1,19 @@
import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js"
import { useLayout } from "@/context/layout"
import { useServer } from "@/context/server"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { base64Encode } from "@opencode-ai/core/util/encode"
export const useSessionKey = () => {
const params = useParams()
const server = useServer()
const scope = createMemo(() => server.scope())
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir)))
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir, params.id)))
const sdk = useSDK()
const serverSDK = useServerSDK()
const scope = createMemo(() => serverSDK().scope)
const directory = createMemo(() => base64Encode(sdk().directory))
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory())))
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory(), params.id)))
return { params, sessionKey, workspaceKey }
}
@@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSettings } from "@/context/settings"
import { useTerminal } from "@/context/terminal"
import { useSDK } from "@/context/sdk"
import { terminalTabLabel } from "@/pages/session/terminal-label"
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
@@ -25,10 +26,11 @@ export function TerminalPanel() {
const delays = [120, 240]
const layout = useLayout()
const terminal = useTerminal()
const sdk = useSDK()
const language = useLanguage()
const command = useCommand()
const settings = useSettings()
const { params, workspaceKey, view } = useSessionLayout()
const { workspaceKey, view } = useSessionLayout()
const opened = createMemo(() => view().terminal.opened())
const size = createSizing()
@@ -122,7 +124,7 @@ export function TerminalPanel() {
})
createEffect(() => {
const dir = params.dir
const dir = sdk().directory
if (!dir) return
if (!terminal.ready()) return
language.locale()
@@ -140,7 +142,7 @@ export function TerminalPanel() {
})
const handoff = createMemo(() => {
const dir = params.dir
const dir = sdk().directory
if (!dir) return []
return getTerminalHandoff(workspaceKey()) ?? []
})
@@ -62,6 +62,8 @@ import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
@@ -71,6 +73,7 @@ import { makeTimer } from "@solid-primitives/timer"
import { scheduleConnectedMeasure } from "./measure"
import { createTimelineProjection } from "./projection"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items"
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
@@ -260,6 +263,7 @@ export function MessageTimeline(props: {
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const tabs = useTabs()
const dialog = useDialog()
const language = useLanguage()
const { params, sessionKey } = useSessionKey()
@@ -449,7 +453,10 @@ export function MessageTimeline(props: {
const id = activeMessageID()
const active = id ? (messageLastRowIndex().get(id) ?? -1) : -1
const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() })
return [...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b)
return filterVirtualIndexes(
[...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
},
})
const resizeItem = virtualizer.resizeItem
@@ -757,12 +764,18 @@ export function MessageTimeline(props: {
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
const href = (id: string) =>
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
navigate(href(parentID))
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
navigate(href(nextSessionID))
return
}
if (params.serverKey) {
tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
return
}
navigate(`/${params.dir}/session`)
@@ -864,7 +877,9 @@ export function MessageTimeline(props: {
const navigateParent = () => {
const id = parentID()
if (!id) return
navigate(`/${params.dir}/session/${id}`)
navigate(
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id),
)
}
function DialogDeleteSession(props: { sessionID: string }) {
@@ -1285,7 +1300,9 @@ export function MessageTimeline(props: {
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true,
"pb-4": true,
"pl-2 pr-3 md:pl-4 md:pr-3": true,
"pr-3": true,
"pl-4": settings.general.newLayoutDesigns(),
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
@@ -0,0 +1,3 @@
export function filterVirtualIndexes(indexes: number[], count: number) {
return indexes.filter((index) => index >= 0 && index < count)
}
@@ -18,6 +18,8 @@ import { createSessionTabs } from "@/pages/session/helpers"
import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
import { useTabs } from "@/context/tabs"
import { requireServerKey } from "@/utils/session-route"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
@@ -45,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const settings = useSettings()
const sync = useSync()
const terminal = useTerminal()
const sessionTabs = useTabs()
const layout = useLayout()
const navigate = useNavigate()
const { params, tabs, view } = useSessionLayout()
@@ -381,7 +384,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
onSelect: () => {
if (params.serverKey) {
sessionTabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
return
}
navigate(`/${params.dir}/session`)
},
}),
sessionCommand({
id: "session.undo",
@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route"
describe("session routes", () => {
test("builds and decodes a server-keyed session route", () => {
const server = ServerConnection.Key.make("https://example.com:4096")
const href = sessionHref(server, "session-1")
expect(href).toBe("/server/aHR0cHM6Ly9leGFtcGxlLmNvbTo0MDk2/session/session-1")
expect(requireServerKey(href.split("/")[2])).toBe(server)
})
test("rejects malformed server keys", () => {
expect(() => requireServerKey("not-base64")).toThrow("Invalid server route")
})
test("builds the legacy directory-keyed route", () => {
expect(legacySessionHref("/Users/example/project", "session-1")).toBe(
"/L1VzZXJzL2V4YW1wbGUvcHJvamVjdA/session/session-1",
)
})
test("resolves the root session", async () => {
const sessions: Record<string, { id: string; parentID?: string }> = {
child: { id: "child", parentID: "parent" },
parent: { id: "parent", parentID: "root" },
root: { id: "root" },
}
expect(
await rootSession(sessions.child, async (id) => {
const session = sessions[id]
if (!session) throw new Error(`Missing session: ${id}`)
return session
}),
).toBe(sessions.root)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { ServerConnection } from "@/context/server"
import { decode64 } from "@/utils/base64"
export function sessionHref(server: ServerConnection.Key, sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
export function legacySessionHref(directory: string, sessionID: string) {
return `/${base64Encode(directory)}/session/${sessionID}`
}
export function requireServerKey(segment: string | undefined) {
const key = decode64(segment)
if (!key || base64Encode(key) !== segment) throw new Error("Invalid server route")
return ServerConnection.Key.make(key)
}
type SessionParent = { id: string; parentID?: string }
export async function rootSession(session: SessionParent, get: (sessionID: string) => Promise<SessionParent>) {
let current = session
while (current.parentID) current = await get(current.parentID)
return current
}
+21 -6
View File
@@ -24,11 +24,11 @@ export function isWslServer(server: ServerConnection.Any) {
return server.type === "sidecar" && server.variant === "wsl"
}
export function WslAddServerButton() {
export function AddServerMenu(props: { onAddServer: () => void }) {
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const openAdd = () => {
const openAddWsl = () => {
dialog.push(() => (
<Dialog title={language.t("wsl.server.add")} size="large" fit class="settings-v2-wsl-dialog">
<DialogAddWslServer />
@@ -36,10 +36,25 @@ export function WslAddServerButton() {
))
}
return (
<Show when={platform.wslServers}>
<ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
{language.t("wsl.server.addShort")}
</ButtonV2>
<Show
when={platform.wslServers}
fallback={
<ButtonV2 variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
{language.t("dialog.server.add.button")}
</ButtonV2>
}
>
<MenuV2 gutter={4} modal={false} placement="bottom-end">
<MenuV2.Trigger as={ButtonV2} variant="ghost-muted" icon="plus">
{language.t("dialog.server.add.button")}
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={props.onAddServer}>{language.t("dialog.server.add.button")}</MenuV2.Item>
<MenuV2.Item onSelect={openAddWsl}>{language.t("wsl.server.add")}</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Show>
)
}
@@ -0,0 +1,71 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
import type { AsyncStorage } from "@solid-primitives/storage"
import { createEffect, createRoot } from "solid-js"
import { ServerScope } from "@/utils/server-scope"
let Prompt: typeof import("@/context/prompt")
let read: ((value: string | null) => void) | undefined
const storage: AsyncStorage = {
getItem: () => new Promise((resolve) => (read = resolve)),
setItem: async () => undefined,
removeItem: async () => undefined,
clear: async () => undefined,
key: async () => null,
getLength: async () => 0,
length: Promise.resolve(0),
}
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useParams: () => ({}),
useSearchParams: () => [{}],
useLocation: () => ({ pathname: "", query: {} }),
useNavigate: () => () => undefined,
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
use: () => undefined,
provider: () => undefined,
}),
}))
mock.module("@/context/platform", () => ({
usePlatform: () => ({ platform: "desktop", storage: () => storage }),
}))
Prompt = await import("@/context/prompt")
})
describe("prompt persistence", () => {
test("waits for an async draft to hydrate before reporting ready", async () => {
await new Promise<void>((resolve, reject) => {
createRoot((dispose) => {
const session = Prompt.createPromptSession(ServerScope.local, { draftID: "draft-async" })
const ready = Prompt.createPromptReady(() => session)
expect(ready()).toBe(false)
expect(session.current()[0]).toMatchObject({ type: "text", content: "" })
read?.(
JSON.stringify({
prompt: [{ type: "text", content: "persisted draft", start: 0, end: 15 }],
cursor: 15,
context: { items: [] },
}),
)
createEffect(() => {
if (!ready()) return
try {
expect(session.current()[0]).toMatchObject({ type: "text", content: "persisted draft" })
dispose()
resolve()
} catch (error) {
dispose()
reject(error)
}
})
})
})
})
})
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test"
import { createVirtualizer } from "@tanstack/solid-virtual"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { createRoot, createSignal } from "solid-js"
import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items"
test("reactive count updates preserve measured row sizes", () => {
createRoot((dispose) => {
@@ -44,3 +45,26 @@ test("logical scroll offset includes pending measurement adjustments", () => {
dispose()
})
})
test("stale pinned indexes do not produce missing virtual items after count shrinks", () => {
createRoot((dispose) => {
const [count, setCount] = createSignal(2)
const pinned = [1]
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
get count() {
return count()
},
getScrollElement: () => null,
estimateSize: () => 60,
initialRect: { width: 800, height: 600 },
rangeExtractor: (range) =>
filterVirtualIndexes([...new Set([...defaultRangeExtractor(range), ...pinned])], range.count),
})
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([0, 1])
setCount(1)
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([0])
expect(() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item]))).not.toThrow()
dispose()
})
})
+5 -3
View File
@@ -27,9 +27,11 @@ export default Runtime.handler(
function listen(hostname: string, port: Option.Option<number>, password: string) {
if (Option.isSome(port)) return bind(hostname, port.value, password)
// Preserve the familiar default when available, but let the OS choose a free
// port when another local server already owns 4096.
return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password)))
const next = (port: number): ReturnType<typeof bind> =>
bind(hostname, port, password).pipe(
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
)
return next(4096)
}
function bind(hostname: string, port: number, password: string) {
@@ -105,6 +105,7 @@ export async function handler(
const sessionId = input.request.headers.get("x-opencode-session") ?? ""
const requestId = input.request.headers.get("x-opencode-request") ?? ""
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
const projectId = input.request.headers.get("x-opencode-project") ?? ""
const userAgent = input.request.headers.get("user-agent") ?? ""
logger.metric({
is_stream: isStream,
@@ -193,8 +194,13 @@ export async function handler(
headers: (() => {
const headers = new Headers(input.request.headers)
providerInfo.modifyHeaders(headers, providerInfo.apiKey, stickyId)
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
headers.set(k, headers.get(v)!)
Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => {
if (v === "$ip") return headers.set(k, ip)
if (v === "$session") return headers.set(k, sessionId)
if (v === "$model") return headers.set(k, model)
if (v === "$request") return headers.set(k, requestId)
if (v === "$project") return headers.set(k, projectId)
headers.set(k, v)
})
headers.delete("host")
headers.delete("content-length")
@@ -691,6 +697,7 @@ export async function handler(
logger.metric({
api_key: data.apiKey,
workspace: data.workspaceID,
user_id: data.user.id,
...(() => {
if (data.billing.subscription)
return {
+1 -2
View File
@@ -52,9 +52,8 @@ export namespace ZenData {
api: z.string(),
apiKey: z.union([z.string(), z.record(z.string(), z.string())]),
format: FormatSchema.optional(),
headerMappings: z.record(z.string(), z.string()).optional(),
headerModifier: z.record(z.string(), z.any()).optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
payloadMappings: z.record(z.string(), z.string()).optional(),
adjustCacheUsage: z.boolean().optional(),
budget: z.number().optional(),
})
@@ -132,6 +132,7 @@ function toLakeEvent(time: string, data: Record<string, unknown>) {
error_cause2: string(data, "error.cause2"),
api_key: string(data, "api_key"),
workspace: string(data, "workspace"),
user_id: string(data, "user_id"),
is_subscription: boolean(data, "isSubscription"), // removed
subscription: string(data, "subscription"),
response_length: integer(data, "response_length"),
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./test/preload.ts"]
+2
View File
@@ -91,6 +91,8 @@
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
+2 -32
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db",
"prevIds": ["abd2f920-b822-49af-b8a7-2e48367d424f"],
"id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
"prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
"ddl": [
{
"name": "workspace",
@@ -900,16 +900,6 @@
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'build'",
"generated": null,
"name": "agent",
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "text",
"notNull": true,
@@ -930,26 +920,6 @@
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "replacement_seq",
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "revision",
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "text",
"notNull": false,
+19 -5
View File
@@ -45,15 +45,17 @@ async function generate() {
if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`)
await Bun.write(
target,
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
await formatTypescript(
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
),
)
await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot)
}
await fs.mkdir(full)
await drizzle(temporary, full, "schema")
await Bun.write(schema, renderSchema(await generatedSql(full)))
await Bun.write(registry, renderRegistry(await typescriptMigrations()))
await Bun.write(schema, await formatTypescript(renderSchema(await generatedSql(full))))
await Bun.write(registry, await formatTypescript(renderRegistry(await typescriptMigrations())))
} finally {
await fs.rm(temporary, { recursive: true, force: true })
}
@@ -76,12 +78,12 @@ async function check() {
await fs.mkdir(full)
await drizzle(temporary, full, "schema")
if ((await Bun.file(schema).text()) !== renderSchema(await generatedSql(full))) {
if ((await Bun.file(schema).text()) !== (await formatTypescript(renderSchema(await generatedSql(full))))) {
throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.")
}
const migrations = await typescriptMigrations()
if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) {
if ((await Bun.file(registry).text()) !== (await formatTypescript(renderRegistry(migrations)))) {
throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
}
} finally {
@@ -170,6 +172,18 @@ function escapeTemplate(line: string) {
return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
}
async function formatTypescript(input: string) {
const prettier = await import("prettier")
const typescript = await import("prettier/plugins/typescript")
const estree = await import("prettier/plugins/estree")
return prettier.format(input, {
parser: "typescript",
plugins: [typescript.default, estree.default],
semi: false,
printWidth: 120,
})
}
function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration"
+14 -48
View File
@@ -1,47 +1,17 @@
export * as AgentV2 from "./agent"
import { Array, Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PermissionSchema } from "./permission/schema"
import { ProviderV2 } from "./provider"
import { PositiveInt } from "./schema"
import { Array, Context, Effect, Layer, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { State } from "./state"
export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID"))
export const ID = Agent.ID
export type ID = typeof ID.Type
export const defaultID = ID.make("build")
export const Color = Schema.Union([
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
export const Color = Agent.Color
export class Info extends Schema.Class<Info>("AgentV2.Info")({
id: ID,
model: ModelV2.Ref.pipe(Schema.optional),
request: ProviderV2.Request,
system: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
mode: Schema.Literals(["subagent", "primary", "all"]),
hidden: Schema.Boolean,
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
permissions: PermissionSchema.Ruleset,
}) {
static empty(id: ID) {
return new Info({
id,
request: {
headers: {},
body: {},
},
mode: "all",
hidden: false,
permissions: [],
})
}
}
export const Info = Agent.Info
export type Info = Agent.Info
export interface Selection {
readonly id: ID
@@ -49,21 +19,19 @@ export interface Selection {
}
type Data = {
agents: Map<ID, Info>
agents: Map<ID, Types.DeepMutable<Info>>
default?: ID
}
export type Editor = {
export type Draft = {
list: () => readonly Info[]
get: (id: ID) => Info | undefined
default: (id: ID | undefined) => void
update: (id: ID, fn: (agent: Draft<Info>) => void) => void
update: (id: ID, fn: (agent: Types.DeepMutable<Info>) => void) => void
remove: (id: ID) => void
}
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
export interface Interface extends State.Transformable<Draft> {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly default: () => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
@@ -73,21 +41,19 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
enableMapSet()
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = State.create<Data, Editor>({
const state = State.create<Data, Draft>({
initial: () => ({ agents: new Map() }),
editor: (draft) => ({
draft: (draft) => ({
list: () => Array.fromIterable(draft.agents.values()) as Info[],
get: (id) => draft.agents.get(id),
default: (id) => {
draft.default = id
},
update: (id, fn) => {
const current = draft.agents.get(id) ?? castDraft(Info.empty(id))
const current = draft.agents.get(id) ?? (Info.empty(id) as Types.DeepMutable<Info>)
if (!draft.agents.has(id)) draft.agents.set(id, current)
fn(current)
current.id = id
@@ -113,7 +79,7 @@ export const layer = Layer.effect(
return Service.of({
transform: state.transform,
update: state.update,
reload: state.reload,
get: Effect.fn("AgentV2.get")(function* (id) {
return state.get().agents.get(id)
}),
+74 -21
View File
@@ -1,14 +1,27 @@
export * as AISDK from "./aisdk"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Cause, Context, Effect, Layer, Schema } from "effect"
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
import { ModelV2 } from "./model"
import { EventV2 } from "./event"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
import { State } from "./state"
type SDK = any
export interface SDKEvent {
readonly model: ModelV2.Info
readonly package: string
readonly options: Record<string, any>
sdk?: SDK
}
export interface LanguageEvent {
readonly model: ModelV2.Info
readonly sdk: SDK
readonly options: Record<string, any>
language?: LanguageModelV3
}
function wrapSSE(res: Response, ms: number, ctl: AbortController) {
if (typeof ms !== "number" || ms <= 0) return res
if (!res.body) return res
@@ -117,19 +130,70 @@ function initError(providerID: ProviderV2.ID) {
}
export interface Interface {
readonly hook: {
readonly sdk: (
callback: (event: SDKEvent) => Effect.Effect<void> | void,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly language: (
callback: (event: LanguageEvent) => Effect.Effect<void> | void,
) => Effect.Effect<State.Registration, never, Scope.Scope>
}
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {}
export const layer = Layer.effect(
export const locationLayer = Layer.effect(
Service,
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
let sdkHooks: ((event: SDKEvent) => Effect.Effect<void> | void)[] = []
let languageHooks: ((event: LanguageEvent) => Effect.Effect<void> | void)[] = []
const languages = new Map<string, LanguageModelV3>()
const sdks = new Map<string, SDK>()
return Service.of({
const register = <Event>(
hooks: () => ((event: Event) => Effect.Effect<void> | void)[],
update: (hooks: ((event: Event) => Effect.Effect<void> | void)[]) => void,
) =>
Effect.fn("AISDK.hook")(function* (callback: (event: Event) => Effect.Effect<void> | void) {
const scope = yield* Scope.Scope
let active = true
update([...hooks(), callback])
const dispose = Effect.sync(() => {
if (!active) return
active = false
update(hooks().filter((item) => item !== callback))
})
yield* Scope.addFinalizer(scope, dispose)
return { dispose }
})
const run = Effect.fnUntraced(function* <Event>(
hooks: readonly ((event: Event) => Effect.Effect<void> | void)[],
event: Event,
) {
for (const hook of hooks) {
const result = hook(event)
if (Effect.isEffect(result)) yield* result
}
return event
})
const service = Service.of({
hook: {
sdk: register(
() => sdkHooks,
(next) => (sdkHooks = next),
),
language: register(
() => languageHooks,
(next) => (languageHooks = next),
),
},
runSDK: (event) => run(sdkHooks, event),
runLanguage: (event) => run(languageHooks, event),
language: Effect.fn("AISDK.language")(function* (model) {
const key = `${model.providerID}/${model.id}/${model.request.variant ?? "default"}`
const existing = languages.get(key)
@@ -148,26 +212,14 @@ export const layer = Layer.effect(
})
const sdk =
sdks.get(sdkKey) ??
(yield* plugin
.trigger("aisdk.sdk", { model, package: model.api.package, options }, {})
.pipe(initError(model.providerID))).sdk
(yield* service.runSDK({ model, package: model.api.package, options }).pipe(initError(model.providerID))).sdk
if (!sdk)
return yield* new InitError({
providerID: model.providerID,
cause: new Error("No AISDK provider plugin returned an SDK"),
})
sdks.set(sdkKey, sdk)
const result = yield* plugin
.trigger(
"aisdk.language",
{
model,
sdk,
options,
},
{},
)
.pipe(initError(model.providerID))
const result = yield* service.runLanguage({ model, sdk, options }).pipe(initError(model.providerID))
const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.api.id)).pipe(
initError(model.providerID),
)
@@ -175,7 +227,8 @@ export const layer = Layer.effect(
return language
}),
})
return service
}),
)
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))))
export const defaultLayer = locationLayer
+56 -103
View File
@@ -1,36 +1,21 @@
export * as Catalog from "./catalog"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema, Scope, Stream } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
import { ModelV2 } from "./model"
import { ModelRequest } from "./model-request"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
import { Location } from "./location"
import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state"
import { Integration } from "./integration"
export type ProviderRecord = {
provider: ProviderV2.Info
models: Map<ModelV2.ID, ModelV2.Info>
provider: ProviderV2.MutableInfo
models: Map<ModelV2.ID, ModelV2.MutableInfo>
}
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"CatalogV2.ProviderNotFound",
{
providerID: ProviderV2.ID,
},
) {}
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("CatalogV2.ModelNotFound", {
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
}) {}
export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = {
@@ -42,16 +27,16 @@ type Data = {
defaultModel?: DefaultModel
}
export type Editor = {
export type Draft = {
provider: {
list: () => readonly ProviderRecord[]
get: (providerID: ProviderV2.ID) => ProviderRecord | undefined
update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void
remove: (providerID: ProviderV2.ID) => void
}
model: {
get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: ModelV2.MutableInfo) => void) => void
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
default: {
get: () => DefaultModel | undefined
@@ -60,44 +45,35 @@ export type Editor = {
}
}
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
export interface Interface extends State.Transformable<Draft> {
readonly provider: {
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined>
readonly all: () => Effect.Effect<ProviderV2.Info[]>
readonly available: () => Effect.Effect<ProviderV2.Info[]>
}
readonly model: {
readonly get: (
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info | undefined>
readonly all: () => Effect.Effect<ModelV2.Info[]>
readonly available: () => Effect.Effect<ModelV2.Info[]>
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<Option.Option<ModelV2.Info>>
readonly default: () => Effect.Effect<ModelV2.Info | undefined>
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
enableMapSet()
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service
const policy = yield* Policy.Service
const integrations = yield* Integration.Service
const scope = yield* Scope.Scope
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
if (provider.disabled) return false
if (typeof provider.request.body.apiKey === "string") return true
if (connected) return true
return !integration
if (integration?.connections.length) return true
return provider.integrationID === undefined && !integration
}
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
@@ -113,39 +89,33 @@ export const layer = Layer.effect(
...ModelRequest.merge({ ...provider.request, generation: {}, options: {} }, model.request),
variant: model.request.variant,
}
return new ModelV2.Info({
return ModelV2.Info.make({
...model,
api,
request,
})
}
function* getRecord(providerID: ProviderV2.ID) {
const match = state.get().providers.get(providerID)
if (!match) return yield* new ProviderNotFoundError({ providerID })
return match
}
const normalizeApi = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
const normalizeApi = (item: ProviderV2.MutableInfo | ModelV2.MutableInfo) => {
if (typeof item.request.body.baseURL !== "string") return
item.api.url = item.request.body.baseURL
delete item.request.body.baseURL
}
const state = State.create<Data, Editor>({
const state = State.create<Data, Draft>({
initial: () => ({ providers: new Map() }),
editor: (draft) => {
const result: Editor = {
draft: (draft) => {
const result: Draft = {
provider: {
list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[],
get: (providerID) => draft.providers.get(providerID),
update: (providerID, fn) => {
let current = draft.providers.get(providerID)
if (!current) {
current = castDraft({
provider: ProviderV2.Info.empty(providerID),
models: new Map<ModelV2.ID, ModelV2.Info>(),
})
current = {
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
}
draft.providers.set(providerID, current)
}
fn(current.provider)
@@ -160,13 +130,14 @@ export const layer = Layer.effect(
update: (providerID, modelID, fn) => {
let record = draft.providers.get(providerID)
if (!record) {
record = castDraft({
provider: ProviderV2.Info.empty(providerID),
models: new Map<ModelV2.ID, ModelV2.Info>(),
})
record = {
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
}
draft.providers.set(providerID, record)
}
const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID))
const model =
record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo)
if (!record.models.has(modelID)) record.models.set(modelID, model)
fn(model)
model.id = modelID
@@ -186,8 +157,7 @@ export const layer = Layer.effect(
}
return result
},
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
if (policy.hasStatements()) {
for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
@@ -198,25 +168,13 @@ export const layer = Layer.effect(
yield* events.publish(Event.Updated, {})
}),
})
yield* events.subscribe(PluginV2.Event.Added).pipe(
// Plugin registries are location scoped even though the event bus is process scoped.
Stream.filter(
(event) =>
event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID,
),
Stream.runForEach((event) =>
state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
),
Effect.forkIn(scope, { startImmediately: true }),
)
const result: Interface = {
transform: state.transform,
reload: state.reload,
provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
const record = yield* getRecord(providerID)
return record.provider
return state.get().providers.get(providerID)?.provider
}),
all: Effect.fn("CatalogV2.provider.all")(function* () {
@@ -225,23 +183,18 @@ export const layer = Layer.effect(
available: Effect.fn("CatalogV2.provider.available")(function* () {
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
const connections = yield* integrations.connection.list()
return (yield* result.provider.all()).filter((provider) =>
available(
provider,
active.get(Integration.ID.make(provider.id)),
connections.has(Integration.ID.make(provider.id)),
),
available(provider, active.get(provider.integrationID ?? Integration.ID.make(provider.id))),
)
}),
},
model: {
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
const record = yield* getRecord(providerID)
const record = state.get().providers.get(providerID)
if (!record) return
const model = record.models.get(modelID)
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
return projectModel(model, record.provider)
return model && projectModel(model, record.provider)
}),
all: Effect.fn("CatalogV2.model.all")(function* () {
@@ -250,7 +203,7 @@ export const layer = Layer.effect(
Array.flatMap((record) => {
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
}),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
)
}),
@@ -262,31 +215,30 @@ export const layer = Layer.effect(
default: Effect.fn("CatalogV2.model.default")(function* () {
const defaultModel = state.get().defaultModel
if (defaultModel) {
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
if (
Option.isSome(provider) &&
(yield* result.provider.available()).some((item) => item.id === provider.value.id)
) {
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
if (Option.isSome(model) && model.value.enabled) return model
const provider = yield* result.provider.get(defaultModel.providerID)
if (provider && (yield* result.provider.available()).some((item) => item.id === provider.id)) {
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID)
if (model?.enabled) return model
}
}
return pipe(
yield* result.model.available(),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
Array.head,
return Option.getOrUndefined(
pipe(
yield* result.model.available(),
Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
Array.head,
),
)
}),
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID)
if (!record) return Option.none<ModelV2.Info>()
if (!record) return
const provider = record.provider
if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
}
const candidates = pipe(
@@ -302,7 +254,7 @@ export const layer = Layer.effect(
Array.map((model) => ({
model,
cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30),
age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30),
small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
})),
Array.filter((item) => item.cost > 0 && item.age <= 18),
@@ -319,10 +271,12 @@ export const layer = Layer.effect(
)
}
return pipe(
candidates,
Array.filter((item) => item.small),
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
return Option.getOrUndefined(
pipe(
candidates,
Array.filter((item) => item.small),
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
),
)
}),
},
@@ -336,6 +290,5 @@ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const locationLayer = layer.pipe(
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Policy.locationLayer),
)
+12 -21
View File
@@ -1,33 +1,24 @@
export * as CommandV2 from "./command"
import { Context, Effect, Layer, Schema } from "effect"
import { castDraft, type Draft } from "immer"
import { ModelV2 } from "./model"
import { Context, Effect, Layer, Types } from "effect"
import { Command } from "@opencode-ai/schema/command"
import { State } from "./state"
export class Info extends Schema.Class<Info>("CommandV2.Info")({
name: Schema.String,
template: Schema.String,
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: ModelV2.Ref.pipe(Schema.optional),
subtask: Schema.Boolean.pipe(Schema.optional),
}) {}
export const Info = Command.Info
export type Info = Command.Info
export type Data = {
commands: Map<string, Info>
commands: Map<string, Types.DeepMutable<Info>>
}
export type Editor = {
export type Draft = {
list: () => readonly Info[]
get: (name: string) => Info | undefined
update: (name: string, update: (command: Draft<Info>) => void) => void
update: (name: string, update: (command: Types.DeepMutable<Info>) => void) => void
remove: (name: string) => void
}
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
export interface Interface extends State.Transformable<Draft> {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
@@ -37,13 +28,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.sync(() => {
const state = State.create<Data, Editor>({
const state = State.create<Data, Draft>({
initial: () => ({ commands: new Map() }),
editor: (draft) => ({
draft: (draft) => ({
list: () => Array.from(draft.commands.values()) as Info[],
get: (name) => draft.commands.get(name),
update: (name, update) => {
const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" }))
const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable<Info>)
if (!draft.commands.has(name)) draft.commands.set(name, current)
update(current)
current.name = name
@@ -55,7 +46,7 @@ export const layer = Layer.effect(
})
return Service.of({
update: state.update,
reload: state.reload,
transform: state.transform,
get: Effect.fn("CommandV2.get")(function* (name) {
return state.get().commands.get(name)
+2 -2
View File
@@ -3,10 +3,10 @@ export * as Config from "./config"
import path from "path"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
import { PermissionSchema } from "./permission/schema"
import { Policy } from "./policy"
import { AbsolutePath } from "./schema"
import { ConfigAgent } from "./config/agent"
@@ -56,7 +56,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
username: Schema.String.pipe(Schema.optional).annotate({
description: "Username displayed in conversations and used for telemetry identity",
}),
permissions: PermissionSchema.Ruleset.pipe(Schema.optional).annotate({
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
description: "Ordered tool permission rules applied to agent tool use",
}),
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
+2 -2
View File
@@ -1,7 +1,7 @@
export * as ConfigAgent from "./agent"
import { Schema } from "effect"
import { PermissionSchema } from "../permission/schema"
import { Permission } from "@opencode-ai/schema/permission"
import { ConfigProvider } from "./provider"
import { PositiveInt } from "../schema"
@@ -21,5 +21,5 @@ export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
permissions: PermissionSchema.Ruleset.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(Schema.optional),
}) {}
+61 -61
View File
@@ -1,5 +1,6 @@
export * as ConfigAgentPlugin from "./agent"
import { define } from "../../plugin/internal"
import path from "path"
import { Effect, Option, Schema } from "effect"
import { AgentV2 } from "../../agent"
@@ -8,7 +9,6 @@ import { ConfigAgent } from "../agent"
import { ConfigMarkdown } from "../markdown"
import { FSUtil } from "../../fs-util"
import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin"
import { ConfigAgentV1 } from "../../v1/config/agent"
import { ConfigMigrateV1 } from "../../v1/config/migrate"
@@ -33,70 +33,70 @@ const agentKeys = new Set([
"permissions",
])
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-agent"),
effect: Effect.gen(function* () {
const agent = yield* AgentV2.Service
export const Plugin = define({
id: "config-agent",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([entry])
return Effect.gen(function* () {
const files = yield* discover(fs, entry.path)
return yield* Effect.forEach(files, (file) =>
fs.readFileStringSafe(file.filepath).pipe(
Effect.map((content) => content && decode(file, content)),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(
Effect.map((documents) =>
documents.filter((document): document is Config.Document => document !== undefined),
),
)
})
}).pipe(Effect.map((documents) => documents.flat()))
yield* agent.update((editor) => {
const global = documents.flatMap((document) => document.info.permissions ?? [])
const configuredDefault = Config.latest(documents, "default_agent")
if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault))
for (const current of editor.list()) {
editor.update(current.id, (agent) => agent.permissions.push(...global))
}
for (const document of documents) {
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
const agentID = AgentV2.ID.make(id)
if (item.disabled) {
editor.remove(agentID)
continue
}
const exists = editor.get(agentID) !== undefined
editor.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...global)
if (item.model !== undefined) {
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
}
if (item.variant !== undefined && agent.model !== undefined) {
agent.model.variant = ModelV2.VariantID.make(item.variant)
}
if (item.request !== undefined) {
Object.assign(agent.request.headers, item.request.headers ?? {})
Object.assign(agent.request.body, item.request.body ?? {})
}
if (item.system !== undefined) agent.system = item.system
if (item.description !== undefined) agent.description = item.description
if (item.mode !== undefined) agent.mode = item.mode
if (item.hidden !== undefined) agent.hidden = item.hidden
if (item.color !== undefined) agent.color = item.color
if (item.steps !== undefined) agent.steps = item.steps
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
yield* ctx.agent.transform(
Effect.fn(function* (draft) {
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([entry])
return Effect.gen(function* () {
const files = yield* discover(fs, entry.path)
return yield* Effect.forEach(files, (file) =>
fs.readFileStringSafe(file.filepath).pipe(
Effect.map((content) => content && decode(file, content)),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(
Effect.map((documents) =>
documents.filter((document): document is Config.Document => document !== undefined),
),
)
})
}).pipe(Effect.map((documents) => documents.flat()))
const global = documents.flatMap((document) => document.info.permissions ?? [])
const configuredDefault = Config.latest(documents, "default_agent")
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...global))
}
}
})
for (const document of documents) {
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
const agentID = AgentV2.ID.make(id)
if (item.disabled) {
draft.remove(agentID)
continue
}
const exists = draft.get(agentID) !== undefined
draft.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...global)
if (item.model !== undefined) {
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
}
if (item.variant !== undefined && agent.model !== undefined) {
agent.model.variant = ModelV2.VariantID.make(item.variant)
}
if (item.request !== undefined) {
Object.assign(agent.request.headers, item.request.headers ?? {})
Object.assign(agent.request.body, item.request.body ?? {})
}
if (item.system !== undefined) agent.system = item.system
if (item.description !== undefined) agent.description = item.description
if (item.mode !== undefined) agent.mode = item.mode
if (item.hidden !== undefined) agent.hidden = item.hidden
if (item.color !== undefined) agent.color = item.color
if (item.steps !== undefined) agent.steps = item.steps
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
})
}
}
}),
)
}),
})
+32 -33
View File
@@ -1,52 +1,51 @@
export * as ConfigCommandPlugin from "./command"
import { define } from "../../plugin/internal"
import path from "path"
import { Effect, Option, Schema } from "effect"
import { CommandV2 } from "../../command"
import { Config } from "../../config"
import { FSUtil } from "../../fs-util"
import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin"
import { ConfigCommand } from "../command"
import { ConfigMarkdown } from "../markdown"
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-command"),
effect: Effect.gen(function* () {
const command = yield* CommandV2.Service
export const Plugin = define({
id: "config-command",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const transform = yield* command.transform()
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
return loadDirectory(fs, entry.path).pipe(
Effect.map((commands) => [
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
]),
)
}).pipe(Effect.map((documents) => documents.flat()))
yield* transform((editor) => {
for (const document of documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
editor.update(name, (item) => {
item.template = command.template
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined) {
const model = ModelV2.parse(command.model)
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
}
if (command.variant !== undefined && item.model !== undefined) {
item.model.variant = ModelV2.VariantID.make(command.variant)
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
yield* ctx.command.transform(
Effect.fn(function* (draft) {
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
return loadDirectory(fs, entry.path).pipe(
Effect.map((commands) => [
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
]),
)
}).pipe(Effect.map((documents) => documents.flat()))
for (const document of documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
draft.update(name, (item) => {
item.template = command.template
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined) {
const model = ModelV2.parse(command.model)
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
}
if (command.variant !== undefined && item.model !== undefined) {
item.model.variant = ModelV2.VariantID.make(command.variant)
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
}
}
}
})
}),
)
}),
})
@@ -0,0 +1,91 @@
export * as ConfigExternalPlugin from "./external"
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
import { Effect, Schema } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../../config"
import { FSUtil } from "../../fs-util"
import { Location } from "../../location"
import { Npm } from "../../npm"
import { define } from "../../plugin/internal"
import { PluginPromise } from "../../plugin/promise"
const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
effect: Schema.declare<EffectPlugin["effect"]>(
(input): input is EffectPlugin["effect"] => typeof input === "function",
),
}),
Schema.Struct({
id: Schema.String,
setup: Schema.declare<PromisePlugin["setup"]>(
(input): input is PromisePlugin["setup"] => typeof input === "function",
),
}),
]),
})
export const Plugin = define({
id: "config-plugin",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
yield* Effect.gen(function* () {
const configured: { package: string; options?: Record<string, any> }[] = []
for (const entry of yield* config.entries()) {
if (entry.type === "document") {
const directory = entry.path ? path.dirname(entry.path) : location.directory
for (const item of entry.info.plugins ?? []) {
const ref = typeof item === "string" ? { package: item } : item
const packageName = (() => {
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
if (ref.package.startsWith("./") || ref.package.startsWith("../")) {
return path.resolve(directory, ref.package)
}
return ref.package
})()
configured.push({ package: packageName, options: ref.options })
}
}
if (entry.type === "directory") {
const files = yield* fs
.glob("{plugin,plugins}/*.{ts,js}", {
cwd: entry.path,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
files.sort()
for (const file of files) configured.push({ package: file })
}
}
for (const ref of configured) {
yield* Effect.gen(function* () {
const entrypoint = path.isAbsolute(ref.package)
? pathToFileURL(ref.package).href
: (yield* npm.add(ref.package)).entrypoint
if (!entrypoint) return
const mod = yield* Effect.promise(() => import(entrypoint))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
yield* ctx.plugin.add({
id: plugin.id,
effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }),
})
}).pipe(Effect.ignoreCause)
}
}).pipe(Effect.forkScoped({ startImmediately: true }))
}),
})
+109 -108
View File
@@ -1,123 +1,124 @@
export * as ConfigProviderPlugin from "./provider"
import { define } from "../../plugin/internal"
import { Effect } from "effect"
import { Catalog } from "../../catalog"
import { Config } from "../../config"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-provider"),
effect: Effect.gen(function* () {
const catalog = yield* Catalog.Service
export const Plugin = define({
id: "config-provider",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const integrations = yield* Integration.Service
const transform = yield* catalog.transform()
const integrationTransform = yield* integrations.transform()
const entries = yield* config.entries()
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredIntegrations = new Set(
files.flatMap((file) =>
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
),
)
yield* integrationTransform((integrations) => {
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const integrationID = Integration.ID.make(id)
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.update(integrationID, (integration) => {
integration.name = item.name ?? integration.name
})
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
yield* ctx.integration.transform(
Effect.fn(function* (integrations) {
const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
const configuredIntegrations = new Set(
files.flatMap((file) =>
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
provider.env === undefined ? [] : [id],
),
),
)
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const integrationID = id
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.update(integrationID, (integration) => {
integration.name = item.name ?? integration.name
})
}
}
}
})
yield* transform((catalog) => {
const configuredDefault = Config.latest(entries, "model")
if (configuredDefault !== undefined) {
const model = ModelV2.parse(configuredDefault)
catalog.model.default.set(model.providerID, model.modelID)
}
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const providerID = ProviderV2.ID.make(id)
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) {
Object.assign(provider.request.headers, item.request.headers)
Object.assign(provider.request.body, item.request.body)
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
})
}
})
const providerApi = catalog.provider.get(providerID)?.provider.api
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, ModelV2.ID.make(id), (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
}
if (config.request !== undefined) {
ModelRequest.assign(model.request, {
headers: config.request.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
})
if (config.request.variant !== undefined) model.request.variant = config.request.variant
}
if (config.variants !== undefined) {
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = {
id: variant.id,
headers: {},
body: {},
generation: {},
options: {},
}
model.variants.push(existing)
}
ModelRequest.assign(existing, {
headers: variant.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
})
}
}
if (config.cost !== undefined) {
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? 0,
write: cost.cache?.write ?? 0,
},
}))
}
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
}
}
}
})
}),
)
yield* ctx.catalog.transform(
Effect.fn(function* (catalog) {
const entries = yield* config.entries()
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredDefault = Config.latest(entries, "model")
if (configuredDefault !== undefined) {
const model = ModelV2.parse(configuredDefault)
catalog.model.default.set(model.providerID, model.modelID)
}
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const providerID = id
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) {
Object.assign(provider.request.headers, item.request.headers)
Object.assign(provider.request.body, item.request.body)
}
})
const providerApi = catalog.provider.get(providerID)?.provider.api
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
}
if (config.request !== undefined) {
ModelRequest.assign(model.request, {
headers: config.request.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
})
if (config.request.variant !== undefined) model.request.variant = config.request.variant
}
if (config.variants !== undefined) {
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = {
id: variant.id,
headers: {},
body: {},
generation: {},
options: {},
}
model.variants.push(existing)
}
ModelRequest.assign(existing, {
headers: variant.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
})
}
}
if (config.cost !== undefined) {
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? 0,
write: cost.cache?.write ?? 0,
},
}))
}
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
}
}
}
}),
)
}),
})
+41 -42
View File
@@ -1,57 +1,56 @@
export * as ConfigReferencePlugin from "./reference"
import { define } from "../../plugin/internal"
import path from "path"
import { Effect } from "effect"
import { Config } from "../../config"
import { ConfigReference } from "../reference"
import { Global } from "../../global"
import { Location } from "../../location"
import { PluginV2 } from "../../plugin"
import { Reference } from "../../reference"
import { AbsolutePath } from "../../schema"
import { Global } from "../../global"
import { Location } from "../../location"
export const Plugin = {
id: PluginV2.ID.make("core/config-reference"),
effect: Effect.gen(function* () {
export const Plugin = define({
id: "core/config-reference",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const global = yield* Global.Service
const location = yield* Location.Service
const references = yield* Reference.Service
const update = yield* references.transform()
const entries = new Map<string, Reference.Source>()
for (const doc of (yield* config.entries()).filter(
(entry): entry is Config.Document => entry.type === "document",
)) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
entries.set(
name,
local(entry)
? new Reference.LocalSource({
type: "local",
path: AbsolutePath.make(
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
),
description: typeof entry === "string" ? undefined : entry.description,
hidden: typeof entry === "string" ? undefined : entry.hidden,
})
: new Reference.GitSource({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
branch: typeof entry === "string" ? undefined : entry.branch,
description: typeof entry === "string" ? undefined : entry.description,
hidden: typeof entry === "string" ? undefined : entry.hidden,
}),
)
}
}
yield* update((editor) => {
for (const [name, source] of entries) editor.add(name, source)
})
const global = yield* Global.Service
yield* ctx.reference.transform(
Effect.fn(function* (draft) {
const entries = new Map<string, Reference.Source>()
for (const doc of (yield* config.entries()).filter(
(entry): entry is Config.Document => entry.type === "document",
)) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
entries.set(
name,
local(entry)
? Reference.LocalSource.make({
type: "local",
path: AbsolutePath.make(
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
),
description: typeof entry === "string" ? undefined : entry.description,
hidden: typeof entry === "string" ? undefined : entry.hidden,
})
: Reference.GitSource.make({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
branch: typeof entry === "string" ? undefined : entry.branch,
description: typeof entry === "string" ? undefined : entry.description,
hidden: typeof entry === "string" ? undefined : entry.hidden,
}),
)
}
}
for (const [name, source] of entries) draft.add(name, source)
}),
)
}),
}
})
function validAlias(name: string) {
return name.length > 0 && !/[\/\s`,]/.test(name)
+36 -34
View File
@@ -1,48 +1,50 @@
export * as ConfigSkillPlugin from "./skill"
import { define } from "../../plugin/internal"
import path from "path"
import { Effect } from "effect"
import { Config } from "../../config"
import { Global } from "../../global"
import { Location } from "../../location"
import { PluginV2 } from "../../plugin"
import { AbsolutePath } from "../../schema"
import { SkillV2 } from "../../skill"
import { Global } from "../../global"
import { Location } from "../../location"
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-skill"),
effect: Effect.gen(function* () {
export const Plugin = define({
id: "config-skill",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const global = yield* Global.Service
const location = yield* Location.Service
const skill = yield* SkillV2.Service
const transform = yield* skill.transform()
const entries = yield* config.entries()
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
yield* transform((editor) => {
for (const directory of directories) {
editor.source(
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
)
editor.source(
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
)
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
editor.source(new SkillV2.UrlSource({ type: "url", url: item }))
continue
yield* ctx.skill.transform(
Effect.fn(function* (draft) {
const entries = yield* config.entries()
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
for (const directory of directories) {
draft.source(
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
)
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
}
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
editor.source(
new SkillV2.DirectorySource({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
)
}
})
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
continue
}
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
)
}
}),
)
}),
})
+23 -38
View File
@@ -2,60 +2,45 @@ export * as Credential from "./credential"
import { asc, eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Database } from "./database/database"
import { IntegrationSchema } from "./integration/schema"
import { NonNegativeInt, withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { CredentialTable } from "./credential/sql"
export const ID = Schema.String.pipe(
Schema.brand("Credential.ID"),
withStatics((schema) => ({ create: () => schema.make("cred_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const ID = Credential.ID
export type ID = Credential.ID
export class OAuth extends Schema.Class<OAuth>("Credential.OAuth")({
type: Schema.Literal("oauth"),
methodID: IntegrationSchema.MethodID,
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const OAuth = Credential.OAuth
export type OAuth = Credential.OAuth
export class Key extends Schema.Class<Key>("Credential.Key")({
type: Schema.Literal("key"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Key = Credential.Key
export type Key = Credential.Key
export const Info = Schema.Union([OAuth, Key])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Credential.Info" })
export type Info = Schema.Schema.Type<typeof Info>
export const Value = Credential.Value
export type Value = Credential.Value
export class Stored extends Schema.Class<Stored>("Credential.Stored")({
export class Info extends Schema.Class<Info>("Credential.Info")({
id: ID,
integrationID: IntegrationSchema.ID,
integrationID: Integration.ID,
label: Schema.String,
value: Info,
value: Value,
}) {}
export interface Interface {
/** Returns every stored credential. */
readonly all: () => Effect.Effect<Stored[]>
readonly all: () => Effect.Effect<Info[]>
/** Returns stored credentials belonging to one integration. */
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
readonly list: (integrationID: Integration.ID) => Effect.Effect<Info[]>
/** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Stored | undefined>
readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Replaces any credential for an integration and returns the new record. */
readonly create: (input: {
readonly integrationID: IntegrationSchema.ID
readonly value: Info
readonly integrationID: Integration.ID
readonly value: Value
readonly label?: string
}) => Effect.Effect<Stored>
}) => Effect.Effect<Info>
/** Updates the label or secret value of a stored credential. */
readonly update: (id: ID, updates: Partial<Pick<Stored, "label" | "value">>) => Effect.Effect<void>
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
/** Removes a stored credential. */
readonly remove: (id: ID) => Effect.Effect<void>
}
@@ -66,10 +51,10 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const decode = Schema.decodeUnknownSync(Info)
const decode = Schema.decodeUnknownSync(Value)
const stored = (row: typeof CredentialTable.$inferSelect) => {
if (!row.integration_id) return
return new Stored({
return new Info({
id: row.id,
integrationID: row.integration_id,
label: row.label,
@@ -106,7 +91,7 @@ export const layer = Layer.effect(
return row ? stored(row) : undefined
}),
create: Effect.fn("Credential.create")(function* (input) {
const credential = new Stored({
const credential = new Info({
id: ID.create(),
integrationID: input.integrationID,
label: input.label ?? "default",
+2 -3
View File
@@ -1,13 +1,12 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import type { IntegrationSchema } from "../integration/schema"
import type { Credential } from "../credential"
export const CredentialTable = sqliteTable("credential", {
id: text().$type<Credential.ID>().primaryKey(),
integration_id: text().$type<IntegrationSchema.ID>(),
integration_id: text().$type<Credential.Info["integrationID"]>(),
label: text().notNull(),
value: text({ mode: "json" }).$type<Credential.Info>().notNull(),
value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
connector_id: text(),
method_id: text(),
active: integer({ mode: "boolean" }),
+3
View File
@@ -37,5 +37,8 @@ export const migrations = (
import("./migration/20260611035744_credential"),
import("./migration/20260611192811_lush_chimera"),
import("./migration/20260612174303_project_dir_strategy"),
import("./migration/20260622142730_simplify_session_context_epoch"),
import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260622142730_simplify_session_context_epoch",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`agent\`;`)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`replacement_seq\`;`)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`revision\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,17 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260622170816_reset_v2_session_state",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_context_epoch\`;`)
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
yield* tx.run(`DELETE FROM \`workspace\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,17 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260622202450_simplify_session_input",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_context_epoch\`;`)
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
yield* tx.run(`DELETE FROM \`workspace\`;`)
})
},
} satisfies DatabaseMigration.Migration
-3
View File
@@ -149,11 +149,8 @@ export default {
CREATE TABLE \`session_context_epoch\` (
\`session_id\` text PRIMARY KEY,
\`baseline\` text NOT NULL,
\`agent\` text DEFAULT 'build' NOT NULL,
\`snapshot\` text NOT NULL,
\`baseline_seq\` integer NOT NULL,
\`replacement_seq\` integer,
\`revision\` integer DEFAULT 0 NOT NULL,
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
+9
View File
@@ -0,0 +1,9 @@
export * as EventManifest from "./event-manifest"
import { Event } from "@opencode-ai/schema/event"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
export const Definitions = Event.inventory(...SessionV1.Events, ...SessionEvent.Definitions)
export const Latest = Event.latest(Definitions)
export const Durable = Event.durable(Definitions)
+138 -242
View File
@@ -1,62 +1,36 @@
export * as EventV2 from "./event"
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event"
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
import { and, asc, eq, gt } from "drizzle-orm"
import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location"
import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { LayerNode } from "./effect/layer-node"
import { isDeepStrictEqual } from "node:util"
import { EventManifest } from "./event-manifest"
export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
Schema.brand("Event.ID"),
withStatics((schema) => ({
create: () => schema.make("evt_" + Identifier.ascending()),
fromExternal: (input: ExternalID) => schema.make(externalID("evt", input)),
})),
)
export type ID = typeof ID.Type
export const ID = Event.ID
export type ID = import("@opencode-ai/schema/event").ID
export type { Data, Definition, Payload } from "@opencode-ai/schema/event"
/**
* Durable aggregate continuation position for embedded replay streams.
* TODO: Decide whether a future HTTP / SDK surface should expose an opaque cursor instead.
*/
export const Cursor = NonNegativeInt.pipe(Schema.brand("EventV2.Cursor"))
export type Cursor = typeof Cursor.Type
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
readonly type: Type
readonly sync?: {
readonly version: number
readonly aggregate: string
}
readonly data: DataSchema
}
export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
export type Payload<D extends Definition = Definition> = {
readonly id: ID
readonly type: D["type"]
readonly data: Data<D>
/** Durable aggregate order, populated while synchronized events are projected. */
readonly seq?: number
readonly version?: number
readonly location?: Location.Ref
readonly metadata?: Record<string, unknown>
/** Internal replay marker for projectors that own non-replicated operational state. */
readonly replay?: boolean
}
export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
type AnyProjector = (event: Payload) => Effect.Effect<void>
export type CommitGuard = (event: Payload) => Effect.Effect<void>
export type Listener = (event: Payload) => Effect.Effect<void>
export type Sync = (event: Payload) => Effect.Effect<void>
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
export type Unsubscribe = Effect.Effect<void>
export const latestSequence = Effect.fn("EventV2.latestSequence")(function* (
db: Database.Interface["db"],
aggregateID: string,
) {
const row = yield* db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
return row?.seq ?? -1
})
export type SerializedEvent = {
readonly id: ID
readonly type: string
@@ -65,82 +39,22 @@ export type SerializedEvent = {
readonly data: Record<string, unknown>
}
export type CursorEvent<E extends Payload = Payload> = {
readonly cursor: Cursor
readonly event: E
}
export class InvalidSyncEventError extends Schema.TaggedErrorClass<InvalidSyncEventError>()(
"EventV2.InvalidSyncEvent",
export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDurableEventError>()(
"EventV2.InvalidDurableEvent",
{
type: Schema.String,
message: Schema.String,
},
) {}
export function versionedType(type: string, version: number) {
return `${type}.${version}`
}
export const registry = new Map<string, Definition>()
type SyncDefinition = Definition & {
readonly sync: NonNullable<Definition["sync"]>
readonly encode: (data: unknown) => unknown
readonly decode: (data: unknown) => unknown
}
const syncRegistry = new Map<string, SyncDefinition>()
// Synchronized events cross a JSON boundary, so their data schemas must encode and decode without services.
const syncCodec = (definition: Definition) => definition.data as Schema.Codec<unknown, unknown, never, never>
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
readonly type: Type
readonly sync?: {
readonly version: number
readonly aggregate: string
}
readonly schema: Fields
}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
const Data = Schema.Struct(input.schema)
const Payload = Schema.Struct({
id: ID,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type),
version: Schema.optional(Schema.Number),
location: Schema.optional(Location.Ref),
data: Data,
}).annotate({ identifier: input.type })
const definition = Object.assign(Payload, {
type: input.type,
...(input.sync === undefined ? {} : { sync: input.sync }),
data: Data,
})
const existing = registry.get(input.type)
if (input.sync === undefined || existing?.sync === undefined || input.sync.version >= existing.sync.version) {
registry.set(input.type, definition)
}
if (input.sync)
syncRegistry.set(
versionedType(input.type, input.sync.version),
Object.assign(definition, {
encode: Schema.encodeUnknownSync(syncCodec(definition)),
decode: Schema.decodeUnknownSync(syncCodec(definition)),
}) as SyncDefinition,
)
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
Definition<Type, Schema.Struct<Fields>>
}
export function definitions() {
return registry.values().toArray()
}
export const define = Event.define
export const versionedType = Event.versionedType
export interface PublishOptions {
readonly id?: ID
readonly metadata?: Record<string, unknown>
readonly location?: Location.Ref
/** Local operational projection committed atomically with a new synchronized event. Not replayed or serialized. */
/** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
readonly commit?: (seq: number) => Effect.Effect<void>
}
@@ -152,14 +66,10 @@ export interface Interface {
) => Effect.Effect<Payload<D>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
readonly all: () => Stream.Stream<Payload>
readonly aggregateEvents: (input: {
readonly aggregateID: string
readonly after?: Cursor
}) => Stream.Stream<CursorEvent>
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
readonly project: <D extends Definition>(definition: D, projector: Projector<D>) => Effect.Effect<void>
readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream<Payload>
/** @deprecated Use `all()` and consume the returned stream. */
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
readonly replay: (
event: SerializedEvent,
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
@@ -176,43 +86,47 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Ev
export interface LayerOptions {
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
readonly definitions?: ReadonlyArray<Definition>
}
export const layerWith = (options?: LayerOptions) =>
Layer.effect(
Service,
Effect.gen(function* () {
const all = yield* PubSub.unbounded<Payload>()
const synchronized = new Map<string, Set<PubSub.PubSub<void>>>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
const projectors = new Map<string, AnyProjector[]>()
const commitGuards = new Array<CommitGuard>()
const listeners = new Array<Listener>()
const syncHandlers = new Array<Sync>()
const durableDefinitions = Event.durable([...EventManifest.Definitions, ...(options?.definitions ?? [])])
const pubsub = {
all: yield* PubSub.unbounded<Payload>(),
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
typed: new Map<string, PubSub.PubSub<Payload>>(),
}
const projectors = new Map<string, Subscriber[]>()
// Projectors intentionally retain type-only dispatch. Exact type+version dispatch is a separate replay design.
const listeners = new Array<Subscriber>()
const { db } = yield* Database.Service
const getOrCreate = (definition: Definition) =>
Effect.gen(function* () {
const existing = typed.get(definition.type)
const existing = pubsub.typed.get(definition.type)
if (existing) return existing
const pubsub = yield* PubSub.unbounded<Payload>()
typed.set(definition.type, pubsub)
return pubsub
const created = yield* PubSub.unbounded<Payload>()
pubsub.typed.set(definition.type, created)
return created
})
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* PubSub.shutdown(all)
yield* PubSub.shutdown(pubsub.all)
yield* Effect.forEach(
synchronized.values(),
pubsub.durable.values(),
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
{ discard: true },
)
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true })
}),
)
function commitSyncEvent(
function commitDurableEvent(
definition: Definition,
event: Payload,
input?: {
readonly seq: number
@@ -223,29 +137,20 @@ export const layerWith = (options?: LayerOptions) =>
commit?: (seq: number) => Effect.Effect<void>,
) {
return Effect.gen(function* () {
const definition = registry.get(event.type)
const sync = definition?.sync
if (sync) {
if (event.version !== sync.version) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected event version ${sync.version}, got ${event.version}`,
}),
)
}
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
const durable = definition?.durable
if (durable) {
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
if (typeof aggregateID !== "string") {
yield* Effect.die(
new InvalidSyncEventError({
new InvalidDurableEventError({
type: event.type,
message: `Expected string aggregate field ${sync.aggregate}`,
message: `Expected string aggregate field ${durable.aggregate}`,
}),
)
} else {
if (input && input.aggregateID !== aggregateID) {
yield* Effect.die(
new InvalidSyncEventError({
new InvalidDurableEventError({
type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}),
@@ -265,12 +170,13 @@ export const layerWith = (options?: LayerOptions) =>
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
const encoded = syncRegistry
.get(versionedType(definition.type, sync.version))!
.encode(event.data) as Record<string, unknown>
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<
string,
unknown
>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die(
new InvalidSyncEventError({
new InvalidDurableEventError({
type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}),
@@ -285,7 +191,7 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie)
if (
stored?.id === event.id &&
stored.type === versionedType(definition.type, sync.version) &&
stored.type === versionedType(definition.type, durable.version) &&
isDeepStrictEqual(stored.data, encoded)
) {
if (input.ownerID && row?.ownerID == null) {
@@ -299,7 +205,7 @@ export const layerWith = (options?: LayerOptions) =>
return
}
yield* Effect.die(
new InvalidSyncEventError({
new InvalidDurableEventError({
type: event.type,
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
}),
@@ -311,7 +217,7 @@ export const layerWith = (options?: LayerOptions) =>
const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidSyncEventError({
new InvalidDurableEventError({
type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
@@ -325,16 +231,17 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie)
if (stored)
yield* Effect.die(
new InvalidSyncEventError({
new InvalidDurableEventError({
type: event.type,
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}),
)
for (const guard of commitGuards) {
yield* guard(event)
}
const committed = {
...event,
durable: { aggregateID, seq, version: durable.version },
} as Payload
for (const projector of list) {
yield* projector({ ...event, seq } as Payload)
yield* projector(committed)
}
if (commit) yield* commit(seq)
yield* db
@@ -356,7 +263,7 @@ export const layerWith = (options?: LayerOptions) =>
id: event.id,
aggregate_id: aggregateID,
seq,
type: versionedType(definition.type, sync.version),
type: versionedType(definition.type, durable.version),
data: encoded,
},
])
@@ -369,8 +276,8 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie)
if (committed) {
yield* Effect.forEach(
synchronized.get(committed.aggregateID) ?? [],
(pubsub) => PubSub.publish(pubsub, undefined),
pubsub.durable.get(committed.aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
{ discard: true },
)
}
@@ -382,21 +289,26 @@ export const layerWith = (options?: LayerOptions) =>
})
}
function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
return Effect.gen(function* () {
const durable = registry.get(event.type)?.sync !== undefined
if (!durable && commit)
if (!definition?.durable && commit)
return yield* Effect.die(
new InvalidSyncEventError({
new InvalidDurableEventError({
type: event.type,
message: "Local commit hooks require a synchronized event",
message: "Local commit hooks require a durable event",
}),
)
if (durable) {
const committed = yield* commitSyncEvent(event as Payload, undefined, commit)
if (definition?.durable) {
const committed = yield* commitDurableEvent(definition, event as Payload, undefined, commit)
if (committed) {
event = { ...event, seq: committed.seq }
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
event = {
...event,
durable: {
aggregateID: committed.aggregateID,
seq: committed.seq,
version: definition.durable.version,
},
}
yield* notify(event as Payload, true)
return event
}
@@ -406,12 +318,11 @@ export const layerWith = (options?: LayerOptions) =>
})
}
const observe = (event: Payload, kind: "sync" | "listener", observer: (event: Payload) => Effect.Effect<void>) =>
const observe = (event: Payload, observer: (event: Payload) => Effect.Effect<void>) =>
Effect.suspend(() => observer(event)).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
(cause) =>
Effect.logError("Event observer failed", { eventID: event.id, eventType: event.type, kind, cause }),
(cause) => Effect.logError("Event listener failed", { eventID: event.id, eventType: event.type, cause }),
),
)
@@ -419,12 +330,12 @@ export const layerWith = (options?: LayerOptions) =>
return Effect.gen(function* () {
yield* Effect.forEach(
listeners,
(listener) => (isolateListeners ? observe(event, "listener", listener) : listener(event)),
(listener) => (isolateListeners ? observe(event, listener) : listener(event)),
{ discard: true },
)
const pubsub = typed.get(event.type)
if (pubsub) yield* PubSub.publish(pubsub, event)
yield* PubSub.publish(all, event)
const typed = pubsub.typed.get(event.type)
if (typed) yield* PubSub.publish(typed, event)
yield* PubSub.publish(pubsub.all, event)
})
}
@@ -437,11 +348,11 @@ export const layerWith = (options?: LayerOptions) =>
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
return yield* publishEvent(
definition,
{
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
...(location ? { location } : {}),
data,
} as Payload<D>,
@@ -455,27 +366,35 @@ export const layerWith = (options?: LayerOptions) =>
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
) {
return Effect.gen(function* () {
const definition = syncRegistry.get(event.type)
if (!definition) {
const definition = durableDefinitions.get(event.type)
if (!definition?.durable) {
yield* Effect.die(
new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }),
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
)
} else {
const payload = {
id: event.id,
type: definition.type,
version: definition.sync.version,
data: definition.decode(event.data),
replay: true,
data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Payload
const committed = yield* commitSyncEvent(payload, {
const committed = yield* commitDurableEvent(definition, payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
yield* notify({ ...payload, seq: committed.seq }, true)
yield* notify(
{
...payload,
durable: {
aggregateID: committed.aggregateID,
seq: committed.seq,
version: definition.durable.version,
},
},
true,
)
}
}
})
@@ -490,7 +409,7 @@ export const layerWith = (options?: LayerOptions) =>
if (!source) return undefined
if (events.some((event) => event.aggregateID !== source)) {
yield* Effect.die(
new InvalidSyncEventError({
new InvalidDurableEventError({
type: events[0]?.type ?? "unknown",
message: "Replay events must belong to the same aggregate",
}),
@@ -501,7 +420,7 @@ export const layerWith = (options?: LayerOptions) =>
const seq = start + index
if (event.seq !== seq) {
yield* Effect.die(
new InvalidSyncEventError({
new InvalidDurableEventError({
type: event.type,
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
}),
@@ -540,22 +459,18 @@ export const layerWith = (options?: LayerOptions) =>
Stream.map((event) => event as Payload<D>),
)
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => {
const definition = syncRegistry.get(event.type)
if (!definition) {
throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` })
const decodeSerializedEvent = (event: SerializedEvent) => {
const definition = durableDefinitions.get(event.type)
if (!definition?.durable) {
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
}
return {
cursor: Cursor.make(event.seq),
event: {
id: event.id,
type: definition.type,
version: definition.sync.version,
seq: event.seq,
data: definition.decode(event.data),
},
id: event.id,
type: definition.type,
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
data: Schema.decodeUnknownSync(definition.data)(event.data),
}
}
@@ -583,43 +498,40 @@ export const layerWith = (options?: LayerOptions) =>
),
)
const subscribeSynchronized = (aggregateID: string) =>
const subscribeDurable = (aggregateID: string) =>
Effect.gen(function* () {
const pubsub = yield* PubSub.sliding<void>(1)
const subscription = yield* PubSub.subscribe(pubsub)
const wake = yield* PubSub.sliding<void>(1)
const subscription = yield* PubSub.subscribe(wake)
yield* Effect.acquireRelease(
Effect.sync(() => {
const pubsubs = synchronized.get(aggregateID) ?? new Set()
pubsubs.add(pubsub)
synchronized.set(aggregateID, pubsubs)
const wakes = pubsub.durable.get(aggregateID) ?? new Set()
wakes.add(wake)
pubsub.durable.set(aggregateID, wakes)
}),
() =>
Effect.sync(() => {
const pubsubs = synchronized.get(aggregateID)
pubsubs?.delete(pubsub)
if (pubsubs?.size === 0) synchronized.delete(aggregateID)
}).pipe(Effect.andThen(PubSub.shutdown(pubsub))),
const wakes = pubsub.durable.get(aggregateID)
wakes?.delete(wake)
if (wakes?.size === 0) pubsub.durable.delete(aggregateID)
}).pipe(Effect.andThen(PubSub.shutdown(wake))),
)
return subscription
})
const streamEvents = (input: {
readonly aggregateID: string
readonly after?: Cursor
}): Stream.Stream<CursorEvent> =>
const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream<Payload> =>
Stream.unwrap(
Effect.gen(function* () {
const synchronized = yield* subscribeSynchronized(input.aggregateID)
let cursor = input.after ?? -1
const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe(
const wakes = yield* subscribeDurable(input.aggregateID)
let sequence = input.after ?? -1
const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe(
Effect.tap((events) =>
Effect.sync(() => {
cursor = events.at(-1)?.cursor ?? cursor
sequence = events.at(-1)?.durable?.seq ?? sequence
}),
),
)
const historical = yield* read
const live = Stream.fromSubscription(synchronized).pipe(
const live = Stream.fromSubscription(wakes).pipe(
Stream.mapEffect(() => read),
Stream.flattenIterable,
)
@@ -627,7 +539,7 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
listeners.push(listener)
return Effect.sync(() => {
@@ -636,21 +548,7 @@ export const layerWith = (options?: LayerOptions) =>
})
})
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
syncHandlers.push(handler)
return Effect.sync(() => {
const index = syncHandlers.indexOf(handler)
if (index >= 0) syncHandlers.splice(index, 1)
})
})
const beforeCommit = (guard: CommitGuard): Effect.Effect<void> =>
Effect.sync(() => {
commitGuards.push(guard)
})
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
Effect.sync(() => {
const list = projectors.get(definition.type) ?? []
list.push((event) => projector(event as Payload<D>))
@@ -661,10 +559,8 @@ export const layerWith = (options?: LayerOptions) =>
publish,
subscribe,
all: streamAll,
aggregateEvents: streamEvents,
sync,
durable,
listen,
beforeCommit,
project,
replay,
replayAll,
+4 -5
View File
@@ -7,8 +7,8 @@ import { FSUtil } from "./fs-util"
import { Location } from "./location"
import { PositiveInt, RelativePath } from "./schema"
import { FileSystemSearch } from "./filesystem/search"
import { Entry, Match } from "./filesystem/schema"
export { Entry, Match, Submatch } from "./filesystem/schema"
import { Entry, Match } from "@opencode-ai/schema/filesystem"
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
export const ReadInput = Schema.Struct({
path: RelativePath,
@@ -108,10 +108,9 @@ const baseLayer = Layer.effect(
const absolute = path.join(target.absolute, item.name)
const relative = path.relative(target.directory, absolute)
return [
new Entry({
Entry.make({
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
type: item.type,
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
}),
]
})
@@ -123,6 +122,6 @@ const baseLayer = Layer.effect(
}),
)
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer))
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
export const locationLayer = layer
-23
View File
@@ -1,23 +0,0 @@
import { Schema } from "effect"
import { NonNegativeInt, PositiveInt, RelativePath } from "../schema"
export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
path: RelativePath,
type: Schema.Literals(["file", "directory"]),
mime: Schema.String,
}) {}
export const Submatch = Schema.Struct({
text: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
})
export type Submatch = typeof Submatch.Type
export class Match extends Schema.Class<Match>("FileSystem.Match")({
entry: Entry,
line: PositiveInt,
offset: NonNegativeInt,
text: Schema.String,
submatches: Schema.Array(Submatch),
}) {}
+21 -31
View File
@@ -59,12 +59,11 @@ export const ripgrepLayer = Layer.effect(
})
.pipe(
Effect.map((result) =>
result.map(
(entry) =>
new FileSystem.Entry({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
}),
result.map((entry) =>
FileSystem.Entry.make({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
}),
),
),
Effect.orDie,
@@ -85,15 +84,14 @@ export const ripgrepLayer = Layer.effect(
})
.pipe(
Effect.map((result) =>
result.map(
(match) =>
new FileSystem.Match({
...match,
entry: new FileSystem.Entry({
...match.entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
}),
result.map((match) =>
FileSystem.Match.make({
...match,
entry: FileSystem.Entry.make({
...match.entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
}),
}),
),
),
Effect.orDie,
@@ -110,12 +108,9 @@ export const ripgrepLayer = Layer.effect(
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
const relative = item.target
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
const clean = type === "directory" ? relative.slice(0, -path.sep.length) : relative
const absolute = path.resolve(location.directory, clean)
return new FileSystem.Entry({
return FileSystem.Entry.make({
path: RelativePath.make(relative),
type,
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
})
})
}),
@@ -148,14 +143,12 @@ export const fffLayer = Layer.effect(
pageSize: input.limit,
})
if (!found.ok) throw found.error
return found.value.items.map((item) => {
const absolute = path.resolve(location.directory, item.relativePath)
return new FileSystem.Entry({
return found.value.items.map((item) =>
FileSystem.Entry.make({
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
type: "file",
mime: FSUtil.mimeType(absolute),
})
})
}),
)
}),
grep: (input) =>
Effect.sync(() => {
@@ -169,11 +162,10 @@ export const fffLayer = Layer.effect(
if (!found.ok) throw found.error
return found.value.items.map((match) => {
const bytes = Buffer.from(match.lineContent)
return new FileSystem.Match({
entry: new FileSystem.Entry({
return FileSystem.Match.make({
entry: FileSystem.Entry.make({
path: RelativePath.make(match.relativePath.replaceAll("\\", "/")),
type: "file",
mime: FSUtil.mimeType(match.relativePath),
}),
line: match.lineNumber,
offset: match.byteOffset,
@@ -220,11 +212,9 @@ export const fffLayer = Layer.effect(
.sort((a, b) => b.score - a.score || a.path.length - b.path.length)
.map((item) => {
const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "")
const absolute = path.resolve(location.directory, relative)
return new FileSystem.Entry({
return FileSystem.Entry.make({
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
type: item.type,
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
})
})
}),
@@ -232,6 +222,6 @@ export const fffLayer = Layer.effect(
}),
)
export const defaultLayer = Layer.unwrap(
export const locationLayer = Layer.unwrap(
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
)
+6 -1
View File
@@ -14,7 +14,12 @@ export namespace FSUtil {
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
method: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
}) {
override get message() {
const detail = this.cause instanceof Error ? this.cause.message : this.cause && String(this.cause)
return `Filesystem operation failed: ${this.method}${detail ? `: ${detail}` : ""}`
}
}
export type Error = PlatformError | FileSystemError
+2 -35
View File
@@ -1,4 +1,4 @@
import { randomBytes } from "crypto"
import { create as createIdentifier } from "@opencode-ai/schema/identifier"
const prefixes = {
job: "job",
@@ -13,12 +13,6 @@ const prefixes = {
workspace: "wrk",
} as const
const LENGTH = 26
// State for monotonic ID generation
let lastTimestamp = 0
let counter = 0
export function ascending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "ascending", given)
}
@@ -38,35 +32,8 @@ function generateID(prefix: keyof typeof prefixes, direction: "descending" | "as
return given
}
function randomBase62(length: number): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let result = ""
const bytes = randomBytes(length)
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % 62]
}
return result
}
export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
const currentTimestamp = timestamp ?? Date.now()
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}
counter++
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
now = direction === "descending" ? ~now : now
const timeBytes = Buffer.alloc(6)
for (let i = 0; i < 6; i++) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
return prefix + "_" + createIdentifier(direction === "descending", timestamp)
}
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
+136 -152
View File
@@ -1,20 +1,32 @@
export * as Integration from "./integration"
import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Schedule, Schema, Scope, SynchronizedRef } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import {
Cause,
Clock,
Context,
Duration,
Effect,
Exit,
Layer,
Schedule,
Schema,
Scope,
SynchronizedRef,
Types,
} from "effect"
import { Integration } from "@opencode-ai/schema/integration"
import { Credential } from "./credential"
import { IntegrationSchema } from "./integration/schema"
import { withStatics } from "./schema"
import { State } from "./state"
import { Identifier } from "./util/identifier"
import { EventV2 } from "./event"
import { IntegrationConnection } from "./integration/connection"
export const ID = IntegrationSchema.ID
export type ID = IntegrationSchema.ID
export const ID = Integration.ID
export type ID = Integration.ID
export const MethodID = IntegrationSchema.MethodID
export type MethodID = IntegrationSchema.MethodID
export const MethodID = Integration.MethodID
export type MethodID = Integration.MethodID
export const AttemptID = Schema.String.pipe(
Schema.brand("Integration.AttemptID"),
@@ -22,71 +34,39 @@ export const AttemptID = Schema.String.pipe(
)
export type AttemptID = typeof AttemptID.Type
export const When = Schema.Struct({
key: Schema.String,
op: Schema.Literals(["eq", "neq"]),
value: Schema.String,
}).annotate({ identifier: "Integration.When" })
export type When = typeof When.Type
export const When = Integration.When
export type When = Integration.When
export const TextPrompt = Schema.Struct({
type: Schema.Literal("text"),
key: Schema.String,
message: Schema.String,
placeholder: Schema.optional(Schema.String),
when: Schema.optional(When),
}).annotate({ identifier: "Integration.TextPrompt" })
export type TextPrompt = typeof TextPrompt.Type
export const TextPrompt = Integration.TextPrompt
export type TextPrompt = Integration.TextPrompt
export const SelectPrompt = Schema.Struct({
type: Schema.Literal("select"),
key: Schema.String,
message: Schema.String,
options: Schema.Array(
Schema.Struct({
label: Schema.String,
value: Schema.String,
hint: Schema.optional(Schema.String),
}),
),
when: Schema.optional(When),
}).annotate({ identifier: "Integration.SelectPrompt" })
export type SelectPrompt = typeof SelectPrompt.Type
export const SelectPrompt = Integration.SelectPrompt
export type SelectPrompt = Integration.SelectPrompt
export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
export type Prompt = typeof Prompt.Type
export const Prompt = Integration.Prompt
export type Prompt = Integration.Prompt
export const OAuthMethod = Schema.Struct({
id: MethodID,
type: Schema.Literal("oauth"),
label: Schema.String,
prompts: Schema.optional(Schema.Array(Prompt)),
}).annotate({ identifier: "Integration.OAuthMethod" })
export type OAuthMethod = typeof OAuthMethod.Type
export const OAuthMethod = Integration.OAuthMethod
export type OAuthMethod = Integration.OAuthMethod
export const KeyMethod = Schema.Struct({
type: Schema.Literal("key"),
label: Schema.optional(Schema.String),
}).annotate({ identifier: "Integration.KeyMethod" })
export type KeyMethod = typeof KeyMethod.Type
export const KeyMethod = Integration.KeyMethod
export type KeyMethod = Integration.KeyMethod
export const EnvMethod = Schema.Struct({
type: Schema.Literal("env"),
names: Schema.Array(Schema.String),
}).annotate({ identifier: "Integration.EnvMethod" })
export type EnvMethod = typeof EnvMethod.Type
export const EnvMethod = Integration.EnvMethod
export type EnvMethod = Integration.EnvMethod
export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type"))
export type Method = typeof Method.Type
export const Method = Integration.Method
export type Method = Integration.Method
export class Info extends Schema.Class<Info>("Integration.Info")({
id: ID,
name: Schema.String,
methods: Schema.Array(Method),
connections: Schema.Array(IntegrationConnection.Info),
methods: Schema.mutable(Schema.Array(Method)),
connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)),
}) {}
export type Inputs = Readonly<{ [key: string]: string }>
export const Inputs = Integration.Inputs
export type Inputs = Integration.Inputs
export type OAuthAuthorization = {
readonly url: string
@@ -94,11 +74,11 @@ export type OAuthAuthorization = {
} & (
| {
readonly mode: "auto"
readonly callback: Effect.Effect<Credential.Info, unknown>
readonly callback: Effect.Effect<Credential.OAuth, unknown>
}
| {
readonly mode: "code"
readonly callback: (code: string) => Effect.Effect<Credential.Info, unknown>
readonly callback: (code: string) => Effect.Effect<Credential.OAuth, unknown>
}
)
@@ -107,6 +87,7 @@ export interface OAuthImplementation {
readonly method: OAuthMethod
readonly authorize: (inputs: Inputs) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
readonly label?: (credential: Credential.OAuth) => string | undefined
}
export interface KeyImplementation {
@@ -121,10 +102,6 @@ export interface EnvImplementation {
export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
function isOAuthImplementation(implementation: Implementation): implementation is OAuthImplementation {
return implementation.method.type === "oauth"
}
export class Attempt extends Schema.Class<Attempt>("Integration.Attempt")({
attemptID: AttemptID,
url: Schema.String,
@@ -164,27 +141,29 @@ export const Event = {
type: "integration.updated",
schema: {},
}),
ConnectionUpdated: EventV2.define({
type: "integration.connection.updated",
schema: { integrationID: ID },
}),
}
export type Ref = {
id: ID
name: string
}
export const Ref = Integration.Ref
export type Ref = Integration.Ref
type Entry = {
ref: Ref
methods: Method[]
implementations: Map<MethodID, OAuthImplementation>
ref: Types.DeepMutable<Ref>
methods: Types.DeepMutable<Method>[]
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
}
type Data = {
integrations: Map<ID, Entry>
}
export type Editor = {
export type Draft = {
list: () => readonly Ref[]
get: (id: ID) => Ref | undefined
update: (id: ID, update: (integration: Draft<Ref>) => void) => void
update: (id: ID, update: (integration: Types.DeepMutable<Ref>) => void) => void
remove: (id: ID) => void
method: {
list: (integrationID: ID) => readonly Method[]
@@ -193,20 +172,19 @@ export type Editor = {
}
}
export interface Interface {
export interface Interface extends State.Transformable<Draft> {
/** Registers a scoped transform over the integration registry. */
readonly transform: State.Interface<Data, Editor>["transform"]
/** Registers and immediately applies a scoped integration registry update. */
readonly update: State.Interface<Data, Editor>["update"]
/** Returns one integration with its methods and current connections. */
readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Returns all integrations with their methods and current connections. */
readonly list: () => Effect.Effect<Info[]>
readonly connection: {
/** Returns active connections for every registered or credential-backed integration. */
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
/** Returns the active connection for one integration. */
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
readonly active: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
/** Resolves a connection into usable credential material. */
readonly resolve: (
connection: IntegrationConnection.Info,
) => Effect.Effect<Credential.Value | undefined, AuthorizationError>
/** Runs a key method and stores the resulting credential. */
readonly key: (input: {
/** Integration receiving the credential. */
@@ -230,7 +208,7 @@ export interface Interface {
/** Updates a stored credential exposed as a connection. */
readonly update: (
credentialID: Credential.ID,
updates: Partial<Pick<Credential.Stored, "label">>,
updates: Partial<Pick<Credential.Info, "label">>,
) => Effect.Effect<void>
/** Removes a stored credential connection. */
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
@@ -252,8 +230,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
enableMapSet()
const attemptLifetime = Duration.toMillis(Duration.minutes(10))
const terminalRetention = Duration.toMillis(Duration.minutes(1))
const scrubInterval = Duration.seconds(30)
@@ -284,15 +260,17 @@ export const locationLayer = Layer.effect(
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
const state = State.create<Data, Editor>({
const state = State.create<Data, Draft>({
initial: () => ({ integrations: new Map<ID, Entry>() }),
editor: (draft) => ({
draft: (draft) => ({
list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[],
get: (id) => draft.integrations.get(id)?.ref as Ref | undefined,
update: (id, update) => {
const current =
draft.integrations.get(id) ??
castDraft({ ref: { id, name: id } as Ref, methods: [], implementations: new Map() })
const current = draft.integrations.get(id) ?? {
ref: { id, name: id },
methods: [],
implementations: new Map(),
}
if (!draft.integrations.has(id)) draft.integrations.set(id, current)
update(current.ref)
current.ref.id = id
@@ -301,16 +279,14 @@ export const locationLayer = Layer.effect(
method: {
list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [],
update: (implementation) => {
const current =
draft.integrations.get(implementation.integrationID) ??
castDraft({
ref: {
id: implementation.integrationID,
name: implementation.integrationID,
} as Ref,
methods: [],
implementations: new Map<MethodID, OAuthImplementation>(),
})
const current = draft.integrations.get(implementation.integrationID) ?? {
ref: {
id: implementation.integrationID,
name: implementation.integrationID,
},
methods: [],
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
}
if (!draft.integrations.has(implementation.integrationID)) {
draft.integrations.set(implementation.integrationID, current)
}
@@ -319,10 +295,13 @@ export const locationLayer = Layer.effect(
if (method.type !== "oauth" || implementation.method.type !== "oauth") return true
return method.id === implementation.method.id
})
if (index === -1) current.methods.push(castDraft(implementation.method))
else current.methods[index] = castDraft(implementation.method)
if (isOAuthImplementation(implementation)) {
current.implementations.set(implementation.method.id, castDraft(implementation))
if (index === -1) current.methods.push(implementation.method as Types.DeepMutable<Method>)
else current.methods[index] = implementation.method as Types.DeepMutable<Method>
if (implementation.method.type === "oauth") {
current.implementations.set(
implementation.method.id,
implementation as Types.DeepMutable<OAuthImplementation>,
)
}
},
remove: (integrationID, method) => {
@@ -341,39 +320,27 @@ export const locationLayer = Layer.effect(
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
const connected = saved.map((credential) => ({
type: "credential" as const,
id: credential.id,
label: credential.label,
}))
const detected = entry.methods
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
const credentials = saved
.map((credential) => ({
type: "credential" as const,
id: credential.id,
label: credential.label,
}))
.toReversed()
const env = (entry?.methods ?? [])
.filter((method) => method.type === "env")
.flatMap((method) => method.names.filter((name) => process.env[name]))
.map((name) => ({ type: "env" as const, name }))
return [...connected, ...detected]
return [...credentials, ...env]
}
const activeConnection = (
entry: Entry | undefined,
saved: readonly Credential.Stored[],
): IntegrationConnection.Info | undefined => {
const credential = saved.at(-1)
if (credential) return { type: "credential", id: credential.id, label: credential.label }
if (!entry) return
const name = entry.methods
.filter((method) => method.type === "env")
.flatMap((method) => method.names)
.find((name) => process.env[name])
if (name) return { type: "env", name }
}
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
new Info({
id: entry.ref.id,
name: entry.ref.name,
methods: entry.methods,
connections: connections(entry, saved),
connections,
})
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
@@ -387,7 +354,7 @@ export const locationLayer = Layer.effect(
return error instanceof Error ? error.message : String(error)
}
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Info, unknown>) {
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.OAuth, unknown>) {
const now = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(attempts, (current) => {
const attempt = current.get(attemptID)
@@ -399,14 +366,13 @@ export const locationLayer = Layer.effect(
})
if (!result) return
if (Exit.isSuccess(exit)) {
const implementation = state.get().integrations.get(result.integrationID)?.implementations.get(result.methodID)
yield* credentials.create({
integrationID: result.integrationID,
label: result.label,
value:
exit.value.type === "oauth"
? new Credential.OAuth({ ...exit.value, methodID: result.methodID })
: exit.value,
label: result.label ?? implementation?.label?.(exit.value),
value: exit.value,
})
yield* events.publish(Event.ConnectionUpdated, { integrationID: result.integrationID })
yield* events.publish(Event.Updated, {})
}
yield* close(result.scope)
@@ -434,32 +400,41 @@ export const locationLayer = Layer.effect(
return Service.of({
transform: state.transform,
update: state.update,
reload: state.reload,
get: Effect.fn("Integration.get")(function* (id) {
const entry = state.get().integrations.get(id)
if (!entry) return undefined
return project(entry, yield* credentials.list(id))
return project(entry, resolveConnections(entry, yield* credentials.list(id)))
}),
list: Effect.fn("Integration.list")(function* () {
return (yield* Effect.forEach(state.get().integrations.values(), (entry) =>
Effect.gen(function* () {
return project(entry, yield* credentials.list(entry.ref.id))
}),
)).toSorted((a, b) => a.name.localeCompare(b.name))
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
return Array.from(state.get().integrations.values(), (entry) =>
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
).toSorted((a, b) => a.name.localeCompare(b.name))
}),
connection: {
list: Effect.fn("Integration.connection.list")(function* () {
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
return new Map(
new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => {
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
return connection ? [[id, connection] as const] : []
}),
)
}),
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
active: Effect.fn("Integration.connection.active")(function* (id) {
const entry = state.get().integrations.get(id)
return activeConnection(entry, yield* credentials.list(id))
return resolveConnections(entry, yield* credentials.list(id))[0]
}),
resolve: Effect.fn("Integration.connection.resolve")(function* (connection) {
if (connection.type === "env") {
const key = process.env[connection.name]
return key ? Credential.Key.make({ type: "key", key }) : undefined
}
const credential = yield* credentials.get(connection.id)
if (!credential) return undefined
if (credential.value.type === "key") return credential.value
const implementation = state
.get()
.integrations.get(credential.integrationID)
?.implementations.get(credential.value.methodID)
if (!implementation?.refresh) return credential.value
const now = yield* Clock.currentTimeMillis
if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
const value = yield* authorize(implementation.refresh(credential.value))
yield* credentials.update(credential.id, { value })
return value
}),
key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state
@@ -470,8 +445,9 @@ export const locationLayer = Layer.effect(
yield* credentials.create({
integrationID: input.integrationID,
label: input.label,
value: new Credential.Key({ type: "key", key: input.key }),
value: Credential.Key.make({ type: "key", key: input.key }),
})
yield* events.publish(Event.ConnectionUpdated, { integrationID: input.integrationID })
yield* events.publish(Event.Updated, {})
}),
oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
@@ -515,11 +491,19 @@ export const locationLayer = Layer.effect(
})
}),
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
const credential = yield* credentials.get(credentialID)
yield* credentials.update(credentialID, updates)
if (credential) {
yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* events.publish(Event.Updated, {})
}),
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
const credential = yield* credentials.get(credentialID)
yield* credentials.remove(credentialID)
if (credential) {
yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* events.publish(Event.Updated, {})
}),
},
+7 -17
View File
@@ -1,22 +1,12 @@
export * as IntegrationConnection from "./connection"
import { Schema } from "effect"
import { Credential } from "../credential"
import { Connection } from "@opencode-ai/schema/connection"
export const CredentialInfo = Schema.Struct({
type: Schema.Literal("credential"),
id: Credential.ID,
label: Schema.String,
}).annotate({ identifier: "Connection.CredentialInfo" })
export type CredentialInfo = typeof CredentialInfo.Type
export const CredentialInfo = Connection.CredentialInfo
export type CredentialInfo = Connection.CredentialInfo
export const EnvInfo = Schema.Struct({
type: Schema.Literal("env"),
name: Schema.String,
}).annotate({ identifier: "Connection.EnvInfo" })
export type EnvInfo = typeof EnvInfo.Type
export const EnvInfo = Connection.EnvInfo
export type EnvInfo = Connection.EnvInfo
export const Info = Schema.Union([CredentialInfo, EnvInfo])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Connection.Info" })
export type Info = typeof Info.Type
export const Info = Connection.Info
export type Info = Connection.Info
-9
View File
@@ -1,9 +0,0 @@
export * as IntegrationSchema from "./schema"
import { Schema } from "effect"
export const ID = Schema.String.pipe(Schema.brand("Integration.ID"))
export type ID = typeof ID.Type
export const MethodID = Schema.String.pipe(Schema.brand("Integration.MethodID"))
export type MethodID = typeof MethodID.Type
+2 -2
View File
@@ -7,7 +7,7 @@ import { Catalog } from "./catalog"
import { Integration } from "./integration"
import { CommandV2 } from "./command"
import { AgentV2 } from "./agent"
import { PluginBoot } from "./plugin/boot"
import { PluginInternal } from "./plugin/internal"
import { Project } from "./project"
import { ProjectCopy } from "./project/copy"
import { ProjectDirectories } from "./project/directories"
@@ -65,7 +65,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Integration.locationLayer,
CommandV2.locationLayer,
AgentV2.locationLayer,
PluginBoot.locationLayer,
PluginInternal.locationLayer,
ProjectCopy.locationLayer,
FileSystem.locationLayer,
Watcher.locationLayer,
+2 -4
View File
@@ -1,14 +1,12 @@
import { Context, Effect, Layer, Schema } from "effect"
import { Ref } from "@opencode-ai/schema/location"
import { Project } from "./project"
import { AbsolutePath, optionalOmitUndefined } from "./schema"
import { WorkspaceV2 } from "./workspace"
export * as Location from "./location"
export class Ref extends Schema.Class<Ref>("Location.Ref")({
directory: AbsolutePath,
workspaceID: Schema.optional(WorkspaceV2.ID).pipe(Schema.withConstructorDefault(Effect.succeed(undefined))),
}) {}
export { Ref }
export class Info extends Schema.Class<Info>("Location.Info")({
directory: AbsolutePath,
+6 -28
View File
@@ -1,39 +1,17 @@
export * as ModelRequest from "./model-request"
import { Effect, Schema } from "effect"
import { ModelRequest } from "@opencode-ai/schema/model-request"
export const Generation = Schema.Struct({
maxTokens: Schema.Number.pipe(Schema.optional),
temperature: Schema.Number.pipe(Schema.optional),
topP: Schema.Number.pipe(Schema.optional),
topK: Schema.Number.pipe(Schema.optional),
frequencyPenalty: Schema.Number.pipe(Schema.optional),
presencePenalty: Schema.Number.pipe(Schema.optional),
seed: Schema.Number.pipe(Schema.optional),
stop: Schema.String.pipe(Schema.Array, Schema.mutable, Schema.optional),
})
export type Generation = typeof Generation.Type
export const Generation = ModelRequest.Generation
export type Generation = ModelRequest.Generation
export const Request = Schema.Struct({
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.Record(Schema.String, Schema.Any),
generation: Generation.pipe(
Schema.optionalKey,
Schema.withConstructorDefault(Effect.succeed({})),
Schema.withDecodingDefaultKey(Effect.succeed({})),
),
options: Schema.Record(Schema.String, Schema.Any).pipe(
Schema.optionalKey,
Schema.withConstructorDefault(Effect.succeed({})),
Schema.withDecodingDefaultKey(Effect.succeed({})),
),
})
export type Request = typeof Request.Type
export const Request = ModelRequest.Request
export type Request = ModelRequest.Request
interface MutableRequest {
headers: Record<string, string>
body: Record<string, unknown>
generation?: Generation
generation?: Record<string, unknown>
options?: Record<string, unknown>
}
+17 -103
View File
@@ -1,119 +1,33 @@
import { DateTime, Schema } from "effect"
import { DateTimeUtcFromMillis } from "effect/Schema"
import { Types } from "effect"
import { Model } from "@opencode-ai/schema/model"
import { ProviderV2 } from "./provider"
import { ModelRequest } from "./model-request"
export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID"))
export const ID = Model.ID
export type ID = typeof ID.Type
export const VariantID = Schema.String.pipe(Schema.brand("VariantID"))
export const VariantID = Model.VariantID
export type VariantID = typeof VariantID.Type
// Grouping of models, eg claude opus, claude sonnet
export const Family = Schema.String.pipe(Schema.brand("Family"))
export type Family = typeof Family.Type
export const Family = Model.Family
export type Family = Model.Family
export const Capabilities = Schema.Struct({
tools: Schema.Boolean,
// mime patterns, image, audio, video/*, text/*
input: Schema.String.pipe(Schema.Array),
output: Schema.String.pipe(Schema.Array),
})
export type Capabilities = typeof Capabilities.Type
export const Capabilities = Model.Capabilities
export type Capabilities = Model.Capabilities
export const Cost = Schema.Struct({
tier: Schema.Struct({
type: Schema.Literal("context"),
size: Schema.Int,
}).pipe(Schema.optional),
input: Schema.Finite,
output: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
})
export const Cost = Model.Cost
export const Ref = Schema.Struct({
id: ID,
providerID: ProviderV2.ID,
variant: VariantID.pipe(Schema.optional),
})
export const Ref = Model.Ref
export type Ref = typeof Ref.Type
export const Api = Schema.Union([
Schema.Struct({
id: ID,
...ProviderV2.AISDK.fields,
}),
Schema.Struct({
id: ID,
...ProviderV2.Native.fields,
}),
]).pipe(Schema.toTaggedUnion("type"))
export type Api = typeof Api.Type
export const Api = Model.Api
export type Api = Model.Api
export class Info extends Schema.Class<Info>("ModelV2.Info")({
id: ID,
providerID: ProviderV2.ID,
family: Family.pipe(Schema.optional),
name: Schema.String,
api: Api,
capabilities: Capabilities,
request: Schema.Struct({
...ModelRequest.Request.fields,
variant: Schema.String.pipe(Schema.optional),
}),
variants: Schema.Struct({
id: VariantID,
...ModelRequest.Request.fields,
}).pipe(Schema.Array),
time: Schema.Struct({
released: DateTimeUtcFromMillis,
}),
cost: Cost.pipe(Schema.Array),
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
enabled: Schema.Boolean,
limit: Schema.Struct({
context: Schema.Int,
input: Schema.Int.pipe(Schema.optional),
output: Schema.Int,
}),
}) {
static empty(providerID: ProviderV2.ID, modelID: ID): Info {
return new Info({
id: modelID,
providerID,
name: modelID,
api: {
id: modelID,
type: "native",
settings: {},
},
capabilities: {
tools: false,
input: [],
output: [],
},
request: {
headers: {},
body: {},
generation: {},
options: {},
},
variants: [],
time: {
released: DateTime.makeUnsafe(0),
},
cost: [],
status: "active",
enabled: true,
limit: {
context: 0,
output: 0,
},
})
}
export const Info = Model.Info
export type Info = Model.Info
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
api: ProviderV2.MutableApi<Api>
}
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {

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