Compare commits

...

68 Commits

Author SHA1 Message Date
Test 44f4211357 feat(tui): route pasted question answers 2026-06-24 13:58:21 +00:00
Simon Klee 6281e35a18 tui: fix multi-day duration formatting (#33651)
Report elapsed days and remaining hours instead of showing zero days and
the total duration as hours.

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

Closes #33239
2026-06-23 15:55:14 +02:00
Adam ef4500d6af fix(data): ignore table changes
deploy / deploy (push) Has been cancelled
2026-06-23 07:28:11 -05:00
Adam f416f52ae3 Reapply "feat(stats): add user ids to inference metrics"
This reverts commit e0ff4fd1ac.
2026-06-23 07:27:01 -05:00
Adam e0ff4fd1ac Revert "feat(stats): add user ids to inference metrics"
This reverts commit 428ae6d21a.
2026-06-23 07:11:51 -05:00
Adam 428ae6d21a feat(stats): add user ids to inference metrics 2026-06-23 07:05:26 -05:00
Brendan Allan d568fed095 fix(app): tighten mobile utility UI (#32799) 2026-06-23 10:41:05 +00:00
opencode-agent[bot] 58a8d9fcb0 chore: generate 2026-06-23 10:28:43 +00:00
Aarav Sareen d24848359d feat(app): new session progress indicator (#32662)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-06-23 20:27:07 +10:00
300 changed files with 7720 additions and 3216 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
+33 -20
View File
@@ -277,6 +277,7 @@
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
@@ -480,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",
@@ -641,9 +643,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.3.4",
"@opentui/keymap": ">=0.3.4",
"@opentui/solid": ">=0.3.4",
"@opentui/core": ">=0.4.2",
"@opentui/keymap": ">=0.4.2",
"@opentui/solid": ">=0.4.2",
},
"optionalPeers": [
"@opentui/core",
@@ -651,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": {
@@ -947,9 +960,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.4",
"@opentui/core": "0.4.2",
"@opentui/keymap": "0.4.2",
"@opentui/solid": "0.4.2",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1804,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"],
@@ -1854,27 +1869,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
"@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="],
"@opentui/core": ["@opentui/core@0.4.2", "", { "dependencies": { "bun-ffi-structs": "0.2.3", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.2", "@opentui/core-darwin-x64": "0.4.2", "@opentui/core-linux-arm64": "0.4.2", "@opentui/core-linux-arm64-musl": "0.4.2", "@opentui/core-linux-x64": "0.4.2", "@opentui/core-linux-x64-musl": "0.4.2", "@opentui/core-win32-arm64": "0.4.2", "@opentui/core-win32-x64": "0.4.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ulx6RMqftf2fm7Itf9e81GcCDMNY6NAhmnKYhllDOMYD+PxYXR+vomy2bxQNV5ow31RE7s8WQFnb7hWTRUbx2g=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-is+O+sS/l3E9cZXyM9pRF1WhqnE+hYSPYoZkbseR9CthJcaWPGi3R3jUJa1cLj325252jWgxVupnDqFUtKg36w=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ACi42h81DurSeybUAD1XyKT6xmXZcKeTxS54lZFi0CVZh46w0g99vNj8PlQzIFXvvFLT0e0IlRS//eWSWS2zGQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-RjOx2HcjLRtGSy9WrAGSdr5M9SpJuPifPORpImx6Mciovw0ltnE0uoYjIyor82uf6/LExWC7YA2AcAl+YBxayA=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-heNciL2ngPU+kq1h01PHLsxn6Fr8iqTFtbxSdVbhaY3XihuIjkuXyEhFeuoa1lsXY7Bb2gpWnX5EQVWnZsAuDQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9s0s/ooK+AhWP306By3gu+XhzcVEThC2sqKMPK1nQmGDujQhd+xOrtbtfCVcJSx62UzAovC2VNqypvP8vHByOg=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cjv6Bv7l3p/KLNJr5RyqCS0FmRlAGJnkA2IK3S+HkHhCOv/O02S1G+DBUY6POnyjp1eNy95vauustApobhdbig=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mfJZrJ0TNPFRZUzXNsxAPe1YdiWsy/vbTl93+yeXGHPI1B8Qnk9V5hpzSxxEyBGhlTHSfGNtgiO+VrrdRC3kZA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-P2oguG3ng3OMjAdasFSA3GhHaQXtzDUsIRDGbzWFOimpZ/zMemidp+JQ0V8V6XwK6Utk5G0aQ03oBaRCoLyYDw=="],
"@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="],
"@opentui/keymap": ["@opentui/keymap@0.4.2", "", { "dependencies": { "@opentui/core": "0.4.2" }, "peerDependencies": { "@opentui/react": "0.4.2", "@opentui/solid": "0.4.2", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wxBEFfWgm3feqCRLckWg1JH4tbMJinpyK3yobkLTsWJ7PDsM+fPoFMyQ8ieKVdUL2eP6ELTmHvM1bHKShZ7SUQ=="],
"@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="],
"@opentui/solid": ["@opentui/solid@0.4.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zuYXsnrlsMtnXrS7QCYBdPzMtUSonG2LqnJikBR2NjEE2O4zEKvJd48n3eB1igcxjv96tiotTXRNCylYS0SNdQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -3052,7 +3067,7 @@
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.3", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-pgJiXP+hEgFo9qG51J6ItfY4ocs3vniwNzJ9WhoakB3QB2GdzQxX2EXssentPYlB2hOfJrTjO6iIQkWYzUodpg=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
@@ -5410,8 +5425,6 @@
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
+1
View File
@@ -256,6 +256,7 @@ new sst.cloudflare.x.SolidStart("Console", {
SECRET.UpstashRedisRestToken,
AUTH_API_URL,
STRIPE_WEBHOOK_SECRET,
SECRET.SupportApiKey,
DISCORD_INCIDENT_WEBHOOK_URL,
SECRET.HoneycombWebhookSecret,
STRIPE_SECRET_KEY,
+1
View File
@@ -7,6 +7,7 @@ new sst.cloudflare.x.SolidStart("Teams", {
domain: shortDomain,
path: "packages/enterprise",
buildCommand: "bun run build:cloudflare",
link: [SECRET.SupportApiKey],
environment: {
OPENCODE_STORAGE_ADAPTER: "r2",
OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID,
+1
View File
@@ -9,6 +9,7 @@ export const SECRET = {
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
SupportApiKey: new sst.Secret("SUPPORT_API_KEY"),
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
}
+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-oWSGu+SP66Aquy/0Vaq7Bgp8404ZdOWbQX+O7h3jxHU=",
"aarch64-linux": "sha256-UsS0+c+GwtIukmWwQeFbY/3Oaz3t4Q7C6cFMGkmlyAY=",
"aarch64-darwin": "sha256-CArz92ewPmXO+ORFCBkCH8LzMpU/DjyaO4ic7QL0UpI=",
"x86_64-darwin": "sha256-rhnz9gmG6L06wIzfMhTaXDDEf6IbMD32CavqwXoqcUs="
"x86_64-linux": "sha256-JJnJVqP2+NiiRVoTKXjGD09lPRyfz8YMfx0m3jBaUzU=",
"aarch64-linux": "sha256-tY3/IA+iZbOQQGxrcHMvQ1xqByskZyVI+4LZkU0wVjs=",
"aarch64-darwin": "sha256-9qMRrQKKOgQuOcAgAq8oZacVFD7G4nbAfZawmCRuJdY=",
"x86_64-darwin": "sha256-dR2VGxDLoAPEmk8SsEF4dhlPBcb9ubztAuVfwxuAD4M="
}
}
+7
View File
@@ -27,6 +27,13 @@ stdenvNoCC.mkDerivation (finalAttrs: {
writableTmpDirAsHomeHook
];
postPatch = ''
# NOTE: Relax Bun version check to be a warning instead of an error
substituteInPlace packages/script/src/index.ts \
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
'';
configurePhase = ''
runHook preConfigure
+3 -3
View File
@@ -39,9 +39,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.4",
"@opentui/core": "0.4.2",
"@opentui/keymap": "0.4.2",
"@opentui/solid": "0.4.2",
"@tanstack/solid-virtual": "3.13.28",
"@shikijs/stream": "4.2.0",
"ulid": "3.0.1",
+2
View File
@@ -18,6 +18,8 @@ bun run test:bench
The suite contains:
- cold and hot session-tab timing
- home-session click timing split between content and titlebar-tab paint
- single-session tab close timing through stable home restoration
- cached session repaint and mutation tracing
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
@@ -0,0 +1,87 @@
import { expectSessionTitle } from "../../utils/waits"
import { benchmark, expect } from "../benchmark"
import { measureFirstNavigation } from "./first-navigation-probe"
import { fixture } from "./session-timeline-stress.fixture"
import {
installStressSessionTabs,
installTimelineSettings,
mockStressTimeline,
stressDraftHref,
stressSessionHref,
} from "./timeline-test-helpers"
import { waitForStableTimeline } from "./session-tab-switch-probe"
const contentSelector = '[data-message-id], [data-component="prompt-input"]'
const draftID = "draft_first_navigation"
benchmark.describe("performance: first navigation paint", () => {
benchmark("opens an unvisited session tab without a blank frame", async ({ page, report }) => {
await setup(page)
const href = stressSessionHref(fixture.targetID)
const result = await measureFirstNavigation(page, {
href,
destinationPath: href,
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
contentSelector,
navigate: async () => {
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
await expectSessionTitle(page, fixture.expected.targetTitle)
},
})
report(result)
expect(result.summary.blankSamples).toBe(0)
expect(result.summary.unknownSamples).toBe(0)
})
benchmark("opens the new session page before its lazy module is used", async ({ page, report }) => {
await setup(page, draftID)
const href = stressDraftHref(draftID)
const result = await measureFirstNavigation(page, {
href,
destinationPath: href,
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
destinationSelector: '[data-component="prompt-input"]',
contentSelector,
navigate: async () => {
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
},
})
report(result)
expect(result.summary.blankSamples).toBe(0)
expect(result.summary.unknownSamples).toBe(0)
})
benchmark("opens a child session without a blank frame", async ({ page, report }) => {
await setup(page)
const href = stressSessionHref(fixture.childID)
const result = await measureFirstNavigation(page, {
href,
destinationPath: href,
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
destinationSelector: messageSelector(fixture.expected.childMessageIDs.at(-1)!),
contentSelector,
navigate: async () => {
await page.locator(`a[href="${href}"]`, { has: page.locator('[data-component="task-tool-card"]') }).click()
await expectSessionTitle(page, fixture.expected.childTitle)
},
})
report(result)
expect(result.summary.blankSamples).toBe(0)
expect(result.summary.unknownSamples).toBe(0)
})
})
async function setup(page: Parameters<typeof mockStressTimeline>[0], draft?: string) {
await mockStressTimeline(page)
await installTimelineSettings(page)
await installStressSessionTabs(page, draft ? { draftID: draft } : undefined)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
}
function messageSelector(id: string) {
return `[data-message-id="${id}"]`
}
@@ -0,0 +1,32 @@
export type FirstNavigationSample = {
observedAtMs: number
source: boolean
destination: boolean
content: boolean
pathname?: string
center?: string
}
function category(sample: FirstNavigationSample) {
if (sample.destination && !sample.source) return "destination"
if (sample.source && !sample.destination) return "source"
if (!sample.content) return "blank"
return "unknown"
}
export function summarizeFirstNavigation(samples: FirstNavigationSample[]) {
const categories = samples.map(category)
const stable = categories.findIndex(
(value, index) =>
value === "destination" && categories[index + 1] === "destination" && categories[index + 2] === "destination",
)
return {
samples: samples.length,
firstDestinationObservedMs: samples[categories.indexOf("destination")]?.observedAtMs ?? null,
stableDestinationObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
sourceSamples: categories.filter((value) => value === "source").length,
blankSamples: categories.filter((value) => value === "blank").length,
unknownSamples: categories.filter((value) => value === "unknown").length,
destinationSamples: categories.filter((value) => value === "destination").length,
}
}
@@ -0,0 +1,86 @@
import type { Page } from "@playwright/test"
import { summarizeFirstNavigation, type FirstNavigationSample } from "./first-navigation-metrics"
type FirstNavigationProbe = {
samples: FirstNavigationSample[]
stop: () => void
}
export async function measureFirstNavigation(
page: Page,
input: {
href: string
destinationPath: string
sourceSelector: string
destinationSelector: string
contentSelector: string
navigate: () => Promise<void>
},
) {
await page.evaluate(
({ href, destinationPath, sourceSelector, destinationSelector, contentSelector }) => {
const samples: FirstNavigationSample[] = []
let started: number | undefined
let running = true
const visible = (selector: string) =>
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
})
const sample = () => {
if (!running || started === undefined) return
requestAnimationFrame(() => {
setTimeout(() => {
if (!running || started === undefined) return
samples.push({
observedAtMs: performance.now() - started,
source: visible(sourceSelector),
destination: `${location.pathname}${location.search}` === destinationPath && visible(destinationSelector),
content: visible(contentSelector),
pathname: `${location.pathname}${location.search}`,
center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
})
sample()
}, 0)
})
}
document.addEventListener(
"click",
(event) => {
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
sample()
},
{ capture: true, once: true },
)
;(window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe = {
samples,
stop: () => {
running = false
},
}
},
{
href: input.href,
destinationPath: input.destinationPath,
sourceSelector: input.sourceSelector,
destinationSelector: input.destinationSelector,
contentSelector: input.contentSelector,
},
)
await input.navigate()
await page.waitForFunction(() => {
const samples = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe
?.samples
if (!samples) return false
return samples.length >= 3 && samples.slice(-3).every((sample) => sample.destination && !sample.source)
})
const samples = await page.evaluate(() => {
const probe = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe!
probe.stop()
return probe.samples
})
return { summary: summarizeFirstNavigation(samples), samples }
}
@@ -0,0 +1,114 @@
import { benchmark, expect } from "../benchmark"
import { expectSessionTitle } from "../../utils/waits"
import { measureNavigationMilestones } from "./navigation-milestones"
import { fixture } from "./session-timeline-stress.fixture"
import {
installStressSessionTabs,
installTimelineSettings,
mockStressTimeline,
stressSessionHref,
} from "./timeline-test-helpers"
import { waitForStableTimeline } from "./session-tab-switch-probe"
const homeRow = '[data-component="home-session-row"]'
const homeShell = '[data-component="home-session-search"]'
benchmark.describe("performance: home and tab navigation", () => {
benchmark("opens a home session and paints its titlebar tab", async ({ page, report }) => {
await setup(page, [])
await page.goto("/")
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
await expect(row).toBeVisible()
const href = stressSessionHref(fixture.targetID)
const result = await measureNavigationMilestones(page, {
triggerSelector: homeRow,
milestones: {
content: { selector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!) },
tab: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]` },
},
navigate: async () => {
await row.click()
await expectSessionTitle(page, fixture.expected.targetTitle)
},
})
report(result)
await expect(page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)).toContainText(
fixture.expected.targetTitle,
)
})
benchmark("stages the review body after cold session content", async ({ page, report }) => {
await setup(page, [])
await page.goto("/")
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
await expect(row).toBeVisible()
const result = await page.evaluate(
({ rowSelector, title, contentSelector }) =>
new Promise<{ contentBeforeReview: boolean; samples: number }>((resolve) => {
let samples = 0
const sample = () => {
samples++
const content = !!document.querySelector(contentSelector)
const review = !!document.querySelector('[data-component="session-review"]')
if (content && !review) {
resolve({ contentBeforeReview: true, samples })
return
}
if (content && review) {
resolve({ contentBeforeReview: false, samples })
return
}
requestAnimationFrame(sample)
}
const target = [...document.querySelectorAll<HTMLElement>(rowSelector)].find((item) =>
item.textContent?.includes(title),
)
if (!target) throw new Error(`Home session row not found: ${title}`)
target.click()
requestAnimationFrame(sample)
}),
{
rowSelector: homeRow,
title: fixture.expected.targetTitle,
contentSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
},
)
report(result)
expect(result.contentBeforeReview).toBe(true)
await expect(page.locator('[data-component="session-review"]')).toBeVisible()
})
benchmark("closes the only session tab and paints home", async ({ page, report }) => {
await setup(page, [fixture.sourceID])
const href = stressSessionHref(fixture.sourceID)
await page.goto(href)
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
const close = tab.locator("..").locator('[data-component="icon-button-v2"]')
await expect(close).toBeVisible()
const result = await measureNavigationMilestones(page, {
triggerSelector: '[data-slot="titlebar-tabs"] [data-component="icon-button-v2"]',
milestones: {
home: { selector: homeShell },
row: { selector: homeRow },
tabRemoved: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]`, visible: false },
},
navigate: async () => {
await close.click()
await expect(page).toHaveURL("/")
},
})
report(result)
})
})
async function setup(page: Parameters<typeof mockStressTimeline>[0], sessionIDs: string[]) {
await mockStressTimeline(page)
await installTimelineSettings(page)
await installStressSessionTabs(page, { sessionIDs })
}
function messageSelector(id: string) {
return `[data-message-id="${id}"]`
}
@@ -0,0 +1,128 @@
import type { Page } from "@playwright/test"
export type NavigationMilestoneSample = {
observedAtMs: number
milestones: Record<string, boolean>
}
export function summarizeNavigationMilestones(samples: NavigationMilestoneSample[]) {
const names = Object.keys(samples[0]?.milestones ?? {})
const summarize = (matches: (sample: NavigationMilestoneSample) => boolean) => {
const first = samples.find(matches)
const stable = samples.findIndex(
(sample, index) =>
index + 2 < samples.length && matches(sample) && matches(samples[index + 1]!) && matches(samples[index + 2]!),
)
return {
firstObservedMs: first?.observedAtMs ?? null,
stableObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
}
}
return {
samples: samples.length,
milestones: Object.fromEntries(
names.map((name) => [name, summarize((sample) => sample.milestones[name] === true)]),
),
all: summarize((sample) => names.every((name) => sample.milestones[name] === true)),
}
}
type NavigationMilestoneProbe = {
samples: NavigationMilestoneSample[]
stop: () => void
}
export async function measureNavigationMilestones(
page: Page,
input: {
triggerSelector: string
milestones: Record<string, { selector: string; visible?: boolean }>
navigate: () => Promise<void>
},
) {
await page.evaluate(
({ triggerSelector, milestones }) => {
const samples: NavigationMilestoneSample[] = []
const streaks = new Map<string, number>()
const marked = new Set<string>()
let started: number | undefined
let running = true
const visible = (selector: string) =>
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
})
const sample = () => {
if (!running || started === undefined) return
requestAnimationFrame(() => {
setTimeout(() => {
if (!running || started === undefined) return
const current = Object.fromEntries(
Object.entries(milestones).map(([name, milestone]) => [
name,
milestone.visible === false ? !document.querySelector(milestone.selector) : visible(milestone.selector),
]),
)
samples.push({
observedAtMs: performance.now() - started,
milestones: current,
})
Object.entries(current).forEach(([name, value]) => {
if (!value) {
streaks.set(name, 0)
return
}
if (!marked.has(`${name}.first`)) {
performance.mark(`opencode.navigation.${name}.first`)
marked.add(`${name}.first`)
}
const streak = (streaks.get(name) ?? 0) + 1
streaks.set(name, streak)
if (streak === 3) performance.mark(`opencode.navigation.${name}.stable`)
})
const all = Object.values(current).every(Boolean)
const allStreak = all ? (streaks.get("all") ?? 0) + 1 : 0
streaks.set("all", allStreak)
if (all && !marked.has("all.first")) {
performance.mark("opencode.navigation.all.first")
marked.add("all.first")
}
if (allStreak === 3) performance.mark("opencode.navigation.all.stable")
sample()
}, 0)
})
}
document.addEventListener(
"click",
(event) => {
if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
started = performance.now()
performance.mark("opencode.navigation.click")
sample()
},
{ capture: true, once: true },
)
;(window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones = {
samples,
stop: () => {
running = false
},
}
},
{ triggerSelector: input.triggerSelector, milestones: input.milestones },
)
await input.navigate()
await page.waitForFunction(() => {
const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
?.samples
if (!samples || samples.length < 3) return false
return samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
})
const samples = await page.evaluate(() => {
const probe = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!
probe.stop()
return probe.samples
})
return { summary: summarizeNavigationMilestones(samples), samples }
}
@@ -47,3 +47,21 @@ benchmark("samples cached session repaint after the click", async ({ page, repor
report(compressCachedRepaintTrace(result))
expect(result.samples.length).toBeGreaterThan(0)
})
benchmark("prefetches every open session tab", async ({ page, report }) => {
const prefetched = new Set<string>()
await mockStressTimeline(page, {
onMessages: (input) => {
if (!input.before && input.phase === "start") prefetched.add(input.sessionID)
},
})
await installStressSessionTabs(page, {
sessionIDs: [fixture.sourceID, fixture.targetID, fixture.childID],
})
await installTimelineSettings(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await expect.poll(() => prefetched.has(fixture.childID)).toBe(true)
report({ prefetched: [...prefetched] })
})
@@ -23,6 +23,7 @@ const words = [
const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target"
const childID = "ses_smoke_child"
const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
@@ -126,9 +127,11 @@ function toolPart(
tool: string,
input: Record<string, unknown>,
outputLength = 160,
metadataOverride?: Record<string, unknown>,
): MessagePart {
const metadata =
tool === "apply_patch"
metadataOverride ??
(tool === "apply_patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write"
? {
@@ -138,7 +141,7 @@ function toolPart(
}
: tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] }
: {}
: {})
return {
id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool",
@@ -244,7 +247,25 @@ function turn(index: number): Message[] {
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
userMessage(sourceID, index + 1000, 120),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [
textPart(index + 1000, 0, 240),
...(index === 11
? [
toolPart(
index + 1000,
1,
"task",
{ description: "Inspect child navigation", subagent_type: "explore" },
160,
{ sessionId: childID },
),
]
: []),
]),
]).flat()
const childMessages = Array.from({ length: 4 }, (_, index) => [
userMessage(childID, index + 2000, 120),
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
]).flat()
function renderable(part: MessagePart) {
@@ -298,19 +319,32 @@ export const fixture = {
version: "dev",
time: { created: 1700000001000, updated: 1700000001000 },
},
{
id: childID,
parentID: sourceID,
slug: "child",
projectID,
directory,
title: "Inspect child navigation",
version: "dev",
time: { created: 1700000002000, updated: 1700000002000 },
},
],
sourceID,
targetID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
childID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages },
expected: {
sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis",
childTitle: "Inspect child navigation",
sourceMessageIDs: sourceMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetMessageIDs: targetMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) =>
orderedParts(message)
.filter(renderable)
@@ -1,7 +1,7 @@
import type { Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { fixture } from "./session-timeline-stress.fixture"
import { fixture, pageMessages } from "./session-timeline-stress.fixture"
export async function installTimelineSettings(page: Page) {
await page.addInitScript(() => {
@@ -9,6 +9,7 @@ export async function installTimelineSettings(page: Page) {
"settings.v3",
JSON.stringify({
general: {
newLayoutDesigns: true,
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
@@ -19,20 +20,24 @@ export async function installTimelineSettings(page: Page) {
})
}
export function mockStressTimeline(page: Page) {
export function mockStressTimeline(
page: Page,
input?: { onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void },
) {
return mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
pageMessages,
onMessages: input?.onMessages,
})
}
export async function installStressSessionTabs(page: Page) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
export async function installStressSessionTabs(page: Page, input?: { draftID?: string; sessionIDs?: string[] }) {
const server = stressServer()
await page.addInitScript(
({ directory, sourceID, targetID, dirBase64, server }) => {
({ directory, sessionIDs, dirBase64, server, draftID }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -42,26 +47,35 @@ export async function installStressSessionTabs(page: Page) {
)
localStorage.setItem(
"opencode.global.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
JSON.stringify([
...sessionIDs.map((sessionId) => ({
type: "session",
server,
dirBase64,
sessionId,
})),
),
...(draftID ? [{ type: "draft", draftID, server, directory }] : []),
]),
)
},
{
directory: fixture.directory,
sourceID: fixture.sourceID,
targetID: fixture.targetID,
sessionIDs: input?.sessionIDs ?? [fixture.sourceID, fixture.targetID],
dirBase64: base64Encode(fixture.directory),
server,
draftID: input?.draftID,
},
)
}
export function stressSessionHref(sessionID: string) {
return `/${base64Encode(fixture.directory)}/session/${sessionID}`
return `/server/${base64Encode(stressServer())}/session/${sessionID}`
}
export function stressDraftHref(draftID: string) {
return `/new-session?draftId=${encodeURIComponent(draftID)}`
}
function stressServer() {
return `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
}
@@ -0,0 +1,32 @@
import { expect, test } from "bun:test"
import { summarizeFirstNavigation } from "../timeline/first-navigation-metrics"
test("reports blank frames before first destination and stable paint", () => {
expect(
summarizeFirstNavigation([
{ observedAtMs: 16, source: true, destination: false, content: true },
{ observedAtMs: 32, source: false, destination: false, content: false },
{ observedAtMs: 48, source: false, destination: true, content: true },
{ observedAtMs: 64, source: false, destination: true, content: true },
{ observedAtMs: 80, source: false, destination: true, content: true },
]),
).toEqual({
samples: 5,
firstDestinationObservedMs: 48,
stableDestinationObservedMs: 80,
sourceSamples: 1,
blankSamples: 1,
unknownSamples: 0,
destinationSamples: 3,
})
})
test("does not report stability for interrupted destination frames", () => {
expect(
summarizeFirstNavigation([
{ observedAtMs: 16, source: false, destination: true, content: true },
{ observedAtMs: 32, source: false, destination: false, content: true },
{ observedAtMs: 48, source: false, destination: true, content: true },
]).stableDestinationObservedMs,
).toBeNull()
})
@@ -0,0 +1,34 @@
import { expect, test } from "bun:test"
import { summarizeNavigationMilestones } from "../timeline/navigation-milestones"
test("reports first and stable paint for each navigation milestone", () => {
expect(
summarizeNavigationMilestones([
{ observedAtMs: 16, milestones: { content: false, tab: false } },
{ observedAtMs: 32, milestones: { content: true, tab: false } },
{ observedAtMs: 48, milestones: { content: true, tab: true } },
{ observedAtMs: 64, milestones: { content: true, tab: true } },
{ observedAtMs: 80, milestones: { content: true, tab: true } },
]),
).toEqual({
samples: 5,
milestones: {
content: { firstObservedMs: 32, stableObservedMs: 64 },
tab: { firstObservedMs: 48, stableObservedMs: 80 },
},
all: { firstObservedMs: 48, stableObservedMs: 80 },
})
})
test("reports missing stability when a milestone appears in the final samples", () => {
expect(
summarizeNavigationMilestones([
{ observedAtMs: 16, milestones: { content: false } },
{ observedAtMs: 32, milestones: { content: true } },
]),
).toEqual({
samples: 2,
milestones: { content: { firstObservedMs: 32, stableObservedMs: null } },
all: { firstObservedMs: 32, stableObservedMs: null },
})
})
@@ -0,0 +1,10 @@
import { expect, test } from "bun:test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { fixture } from "../timeline/session-timeline-stress.fixture"
import { stressSessionHref } from "../timeline/timeline-test-helpers"
test("builds stress session links for the benchmark server", () => {
expect(stressSessionHref(fixture.sourceID)).toBe(
`/server/${base64Encode("http://127.0.0.1:4096")}/session/${fixture.sourceID}`,
)
})
@@ -57,8 +57,8 @@ test("shows a comment button when a line number is hovered", async ({ page }) =>
await page.mouse.move(0, 0)
await lineNumber.hover()
await expect(comment).toBeVisible({ timeout: 500 })
await comment.click({ timeout: 500 })
}).toPass()
await comment.click()
await expect(review.getByRole("textbox")).toBeVisible()
})
@@ -58,7 +58,18 @@ test.describe("smoke: session timeline", () => {
await page.mouse.wheel(0, -120)
await page.waitForTimeout(20)
}
const keys = ["prt_user_text_smoke_0032", "prt_text_2_smoke_0032", "prt_tool_apply_patch_8_smoke_0032"]
const keys = await scroller.evaluate((element) => {
const view = element.getBoundingClientRect()
return [...element.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
.filter((row) => {
const rect = row.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((row) => row.dataset.timelinePartId)
.filter((id): id is string => !!id)
.slice(0, 3)
})
expect(keys.length).toBeGreaterThan(0)
const positions = () =>
scroller.evaluate((element, keys) => {
const top = element.getBoundingClientRect().top
@@ -718,7 +729,6 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
}
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
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()
+11 -4
View File
@@ -26,6 +26,8 @@ export interface MockServerConfig {
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const cursors = new Map<string, string>()
let nextCursor = 0
const staticRoutes: Record<string, unknown> = {
"/provider": config.provider,
"/path": {
@@ -68,13 +70,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) {
const before = url.searchParams.get("before") ?? undefined
const token = url.searchParams.get("before") ?? undefined
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const limit = Number(url.searchParams.get("limit") ?? 80)
const pageData = config.pageMessages(messagesMatch[1], limit, before)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
if (!pageData.cursor) return json(route, pageData.items)
const cursor = `cursor_${++nextCursor}`
cursors.set(cursor, pageData.cursor)
return json(route, pageData.items, { "x-next-cursor": cursor })
}
if (url.port === targetPort && targetPort !== appPort) return json(route, {})
@@ -82,9 +89,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
}
function json(route: Route, body: unknown, headers?: Record<string, string>) {
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
return route.fulfill({
status: 200,
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
+177 -181
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 { keepPreviousData, QueryClient, QueryClientProvider, useQuery } from "@tanstack/solid-query"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
type Component,
@@ -32,7 +32,7 @@ import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider } from "@/context/global"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
@@ -53,56 +53,127 @@ import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route"
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"))
import Session from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home"
const NewSession = lazy(() => import("@/pages/new-session"))
const SessionRoute = Object.assign(
() => {
const settings = useSettings()
const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
const sdk = useSDK()
const server = useServer()
const tabs = useTabs()
const SessionRoute = () => {
const settings = useSettings()
const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
const sdk = useSDK()
const server = useServer()
const tabs = useTabs()
if (params.id && settings.general.newLayoutDesigns()) {
return <Navigate href={sessionHref(server.key, params.id)} />
}
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(() => {
if (!settings.general.newLayoutDesigns()) return
if (params.id || search.draftId) return
if (!tabs.ready() || !sdk().directory) return
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
// is replaced by a draft at /new-session?draftId=…
createEffect(() => {
if (!settings.general.newLayoutDesigns()) return
if (params.id || search.draftId) return
if (!tabs.ready() || !sdk().directory) return
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
})
return (
<SessionProviders>
<Session />
</SessionProviders>
)
}
const TargetSessionRoute = () => {
const params = useParams<{ serverKey: string; id: string }>()
const server = useServer()
const conn = createMemo(() => {
const key = requireServerKey(params.serverKey)
return server.list.find((item) => ServerConnection.key(item) === key)
})
return (
<Show when={`${params.serverKey}\0${params.id}`} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ResolvedTargetSessionRoute />
</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
}
function ResolvedTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
const settings = useSettings()
const tabs = useTabs()
const global = useGlobal()
const serverSDK = useServerSDK()
const serverKey = createMemo(() => requireServerKey(params.serverKey))
const placement = createMemo(() => global.sessionPlacement.get(serverKey(), params.id))
const [resolved] = createResource(
() => {
if (placement()) return
return { id: params.id, sdk: serverSDK() }
},
async ({ id, sdk }) => {
const session = (await sdk.client.session.get({ sessionID: id })).data!
const root = await rootSession(session, (sessionID) =>
sdk.client.session.get({ sessionID }).then((result) => result.data!),
)
return global.sessionPlacement.set({
server: serverKey(),
leafID: session.id,
rootID: root.id,
directory: session.directory,
})
},
)
const directory = createMemo(() => placement()?.directory ?? resolved()?.directory)
const targetDirectory = () => directory()!
createEffect(() => {
const current = placement() ?? resolved()
if (!current) return
tabs.addSessionTab({
server: serverKey(),
sessionId: current.rootID,
})
})
return (
return (
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
<Show when={directory()}>
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<TargetSessionPage />
</DirectoryDataProvider>
</SDKProvider>
</Show>
</Show>
</Show>
</TargetServerScopedProviders>
)
}
function TargetSessionPage() {
const sdk = useSDK()
const serverSDK = useServerSDK()
return (
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
<SessionProviders>
<Session />
</SessionProviders>
)
},
{ 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 },
)
</Show>
)
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
@@ -125,121 +196,42 @@ function LegacyServerLayout(props: ParentProps) {
)
}
// 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 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)
if (!draft) return undefined
return server.list.find((c) => ServerConnection.key(c) === draft.server)
})
return (
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<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) => 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="/" />}>
<ResolvedDraftRoute />
<Show
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
keyed
fallback={<Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
</Show>
</Show>
)
}
function ResolvedDraftRoute() {
function ResolvedDraftRoute(props: { draft: DraftTab }) {
const server = useServer()
const conn = createMemo(() => server.list.find((item) => ServerConnection.key(item) === props.draft.server))
const directory = () => props.draft.directory
const serverKey = () => props.draft.server
return (
<DraftProviders>
<NewSession />
</DraftProviders>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<TargetServerScopedProviders directory={directory}>
<SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
</TargetServerScopedProviders>
</ServerSyncProvider>
</ServerSDKProvider>
)
}
@@ -302,9 +294,7 @@ function SharedProviders(props: ParentProps) {
)
}
// 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.
// Server-scoped providers shared by the legacy shell and the top-level new shell.
type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
sessionID?: () => string | undefined
@@ -330,11 +320,23 @@ function LegacyServerScopedShell(props: ServerScopedShellProps) {
)
}
function NewServerScopedShell(props: ServerScopedShellProps) {
function NewAppLayout(props: ParentProps) {
return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
<NewLayout>{props.children}</NewLayout>
</ServerScopedProviders>
<SelectedServerProviders>
<ServerScopedProviders>
<NewLayout>{props.children}</NewLayout>
</ServerScopedProviders>
</SelectedServerProviders>
)
}
function TargetServerScopedProviders(props: ServerScopedShellProps) {
return (
<PermissionProvider directory={props.directory}>
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider>{props.children}</ModelsProvider>
</NotificationProvider>
</PermissionProvider>
)
}
@@ -520,11 +522,9 @@ export function AppInterface(props: {
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
}) {
// The shared shell holds only server-agnostic providers (QueryClient + Settings/
// Command/Highlights) and stays mounted across every route. The server-scoped
// providers and the visual Layout live in the per-route layouts below, so they
// resolve to that route's server (selected for most routes, the draft's server for
// /new-session). appChildren is server-agnostic, so it renders here once.
// The visual new layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const ServerShell = (shellProps: ParentProps) => (
<QueryProvider>
<SharedProviders>
@@ -548,7 +548,11 @@ export function AppInterface(props: {
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<ServerShell>{routerProps.children}</ServerShell>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</TabsProvider>
)}
>
@@ -574,28 +578,20 @@ function Routes() {
<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()
<Show when={settings.general.newLayoutDesigns()}>
<Route path="/" component={NewHome} />
<Route
path="/:dir/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>
return <Navigate href={`/server/${server.key}/session/${id}`} />
}}
/>
</Show>
<Route path="/new-session" component={DraftRoute} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</>
)
}
+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
@@ -20,7 +20,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
const dialog = useDialog()
const global = useGlobal()
const language = useLanguage()
const serverCtx = createMemo(() => global.createServerCtx(props.server))
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
const serverSDK = () => serverCtx().sdk
const serverSync = () => serverCtx().sync
@@ -19,6 +19,7 @@ import {
pickerMode,
preloadTreeDirectories,
cleanPickerInput,
createPriorityTaskQueue,
createDirectorySearch,
currentPickerSuggestions,
displayPickerPath,
@@ -38,7 +39,7 @@ interface DialogSelectDirectoryV2Props {
export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const global = useGlobal()
const { sync, sdk } = global.createServerCtx(props.server)
const { sync, sdk } = global.ensureServerCtx(props.server)
const dialog = useDialog()
const language = useLanguage()
const policy = pickerMode(props.mode ?? "directory", props.start)
@@ -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
}
@@ -49,7 +49,7 @@ function uniqueRows(rows: Row[]) {
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const global = useGlobal()
const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server)
const { sync, sdk, ...serverCtx } = global.ensureServerCtx(props.server)
const dialog = useDialog()
const language = useLanguage()
@@ -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,
@@ -26,7 +26,7 @@ export function SettingsServerScope(props: ParentProps) {
function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) {
const global = useGlobal()
const serverCtx = () => global.createServerCtx(props.server)
const serverCtx = () => global.ensureServerCtx(props.server)
return (
<QueryClientProvider client={serverCtx().queryClient}>
@@ -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;
+132 -76
View File
@@ -2,6 +2,7 @@ import {
createEffect,
createMemo,
createResource,
createRoot,
createSignal,
For,
Match,
@@ -22,17 +23,15 @@ 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 { 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"
@@ -41,8 +40,9 @@ import { useGlobal } from "@/context/global"
import { ServerConnection, useServer } from "@/context/server"
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>
@@ -101,10 +101,6 @@ 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
@@ -246,8 +242,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}}
style={{
"min-height": minHeight(),
"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()
@@ -262,18 +256,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<Switch>
<Match when={useV2Titlebar()}>
{(_) => {
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
@@ -281,11 +266,15 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const [session] = createResource(
() => {
const route = layout.route()
return route.type === "session" ? route : undefined
if (route.type !== "session") return undefined
const conn = global.servers
.list()
.find((item) => ServerConnection.key(item) === (route.server ?? server.key))
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
},
(route) =>
serverSdk()
.client.session.get({ sessionID: route.sessionId })
({ route, sdk }) =>
sdk.client.session
.get({ sessionID: route.sessionId })
.then((x) => x.data)
.catch(() => {}),
)
@@ -338,7 +327,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.ensureServerCtx(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", () => [
@@ -408,22 +418,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)
})
@@ -487,6 +481,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<For each={tabsStore}>
{(tab, i) => {
let ref!: HTMLDivElement
useTabShortcut(i, () => tabs.select(tab))
const divider = () =>
i() !== 0 && (
@@ -512,14 +507,76 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
)
}
const [session] = createResource(
() => tab.sessionId,
(sessionID) =>
serverSdk()
.client.session.get({ sessionID })
.then((x) => x.data)
const serverCtx = createMemo(() => {
const conn = server.list.find((item) => ServerConnection.key(item) === tab.server)
return conn ? global.ensureServerCtx(conn) : undefined
})
const sdk = createMemo(() => serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => {
const placement = global.sessionPlacement.get(tab.server, tab.sessionId)
const ctx = serverCtx()
if (!placement || !ctx) return
return ctx.sync
.child(placement.directory, { bootstrap: false })[0]
.session.find((session) => session.id === tab.sessionId)
})
const [loadedSession] = createResource(
() => {
if (cachedSession()) return null
const id = tab.sessionId
const ctx = serverCtx()
return ctx ? { id, ctx } : null
},
({ id, ctx }) =>
ctx.sdk.client.session
.get({ sessionID: id })
.then((x) => {
const session = x.data
if (!session) return
if (!session.parentID)
global.sessionPlacement.set({
server: tab.server,
leafID: session.id,
rootID: session.id,
directory: session.directory,
})
return session
})
.catch(() => undefined),
)
const session = createMemo(() => cachedSession() ?? loadedSession())
let prefetched = false
createEffect(() => {
const ctx = serverCtx()
const sess = session()
if (!ctx || !sess || prefetched) return
prefetched = true
createRoot((dispose) => {
try {
void ctx.sync
.ensureDirSyncContext(sess.directory)
.session.sync(sess.id)
.catch(() => {})
.finally(dispose)
} catch {
dispose()
}
})
})
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 (
<>
@@ -593,8 +650,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>
@@ -819,6 +875,26 @@ 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
@@ -841,13 +917,13 @@ function TabNavItem(props: {
const global = useGlobal()
const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.createServerCtx(conn)
if (conn) return global.ensureServerCtx(conn)
})
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
@@ -868,7 +944,7 @@ 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={session().directory}
sessionId={session().id}
@@ -904,26 +980,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
@@ -941,7 +997,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)
@@ -986,7 +1042,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)
+1 -1
View File
@@ -187,7 +187,7 @@ export const createDirSyncContext = (
return serverSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80
const initialMessagePageSize = 2
const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
+4 -1
View File
@@ -8,11 +8,13 @@ import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web"
import { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope"
import { createSessionPlacementStore } from "@/utils/session-placement"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
init: () => {
const server = useServer()
const sessionPlacement = createSessionPlacementStore()
const serverHealth = useServerHealth(
() => server.list,
() => true,
@@ -85,7 +87,8 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
},
},
},
createServerCtx(conn: ServerConnection.Any) {
sessionPlacement,
ensureServerCtx(conn: ServerConnection.Any) {
return ensureServerCtx(conn)
},
}
+6 -7
View File
@@ -7,7 +7,6 @@ import { useServerSync } from "./server-sync"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { Binary } from "@opencode-ai/core/util/binary"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2"
@@ -208,12 +207,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const [syncStore] = serverSync().child(directory, { bootstrap: false })
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
if (match.found) return syncStore.session[match.index]
return serverSDK()
.client.session.get({ directory, sessionID })
.then((x) => x.data)
const sync = serverSync().ensureDirSyncContext(directory)
const session = sync.session.get(sessionID)
if (session) return session
return sync.session
.sync(sessionID)
.then(() => sync.session.get(sessionID))
.catch(() => undefined)
}
+43 -5
View File
@@ -1,13 +1,17 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
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
@@ -181,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()),
@@ -190,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),
@@ -247,19 +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 = () => {
@@ -282,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) {
@@ -308,9 +345,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
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(),
+2 -2
View File
@@ -2,7 +2,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, createMemo } from "solid-js"
import { type ServerSDK, useServerSDK } from "./server-sdk"
export type DirectorySDK = ReturnType<ServerSDK["createDirSdkContext"]>
export type DirectorySDK = ReturnType<ServerSDK["ensureDirSdkContext"]>
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
@@ -11,7 +11,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const serverSDK = useServerSDK()
return createMemo(() => {
const directory = typeof props.directory === "function" ? props.directory() : props.directory
return serverSDK().createDirSdkContext(directory)
return serverSDK().ensureDirSdkContext(directory)
})
},
})
+3 -3
View File
@@ -289,13 +289,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
export type ServerSDK = ServerSDKBase & {
createDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
}
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
const sdk = createServerSdkContextBase(server, scope)
return Object.assign(sdk, {
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
ensureDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
})
}
@@ -311,7 +311,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
return createMemo<ServerSDK>(() => {
const conn = props.server?.() ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
return global.createServerCtx(conn).sdk
return global.ensureServerCtx(conn).sdk
})
},
})
+2 -2
View File
@@ -509,7 +509,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
export function createServerSyncContext(serverSDK: ServerSDK) {
const inner = createServerSyncContextInner(serverSDK)
return Object.assign(inner, {
createDirSyncContext: createRefCountMap(
ensureDirSyncContext: createRefCountMap(
(dir) => createDirSyncContext(dir, inner, serverSDK),
(dir) => inner.disableMcp(dir),
directoryKey,
@@ -531,7 +531,7 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
return createMemo<ServerSync>(() => {
const conn = props.server?.() ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
return global.createServerCtx(conn).sync
return global.ensureServerCtx(conn).sync
})
},
})
+1 -1
View File
@@ -113,7 +113,7 @@ export const useSync = () => {
const serverSync = useServerSync()
const sdk = useSDK()
return createMemo(() => serverSync().createDirSyncContext(sdk().directory))
return createMemo(() => serverSync().ensureDirSyncContext(sdk().directory))
}
export type DirectorySync = ReturnType<ReturnType<typeof useSync>>
+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()
})
})
})
+21 -3
View File
@@ -3,12 +3,13 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
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"
@@ -66,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
@@ -89,11 +91,18 @@ 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)
})
@@ -106,13 +115,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const actions = {
addSessionTab: (tab: Omit<SessionTab, "type">) => {
const next = { type: "session" as const, ...tab }
if (closing.has(tabKey(next))) return
const existing = store.find((item) => tabKey(item) === tabKey(next))
if (existing) return existing
setStore(
produce((tabs) => {
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
tabs.push(next)
}),
)
return next
},
draft(draftID: string) {
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
@@ -151,6 +162,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) => {
@@ -170,12 +182,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("/")
@@ -227,6 +241,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) {
@@ -246,6 +261,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
View File
@@ -15,6 +15,13 @@
font-style: normal;
}
@media (display-mode: standalone) {
/* WebKit excludes safe-area insets from dvh in installed apps. */
#root {
height: 100vh;
}
}
@layer components {
@keyframes session-progress-whip {
0% {
+7 -1
View File
@@ -11,6 +11,7 @@ import { decode64 } from "@/utils/base64"
import { Schema } from "effect"
import type { ServerConnection } from "@/context/server"
import { sessionHref } from "@/utils/session-route"
import { useGlobal } from "@/context/global"
export function DirectoryDataProvider(
props: ParentProps<{
@@ -23,6 +24,7 @@ export function DirectoryDataProvider(
const navigate = useNavigate()
const params = useParams()
const sync = useSync()
const global = useGlobal()
const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
const slug = createMemo(() => base64Encode(directory()))
const href = (sessionID: string) => {
@@ -52,7 +54,11 @@ export function DirectoryDataProvider(
<DataProvider
data={sync().data}
directory={directory()}
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
onNavigateToSession={(sessionID: string) => {
const server = props.server?.()
if (server && params.id) global.sessionPlacement.inherit(server, params.id, sessionID)
navigate(href(sessionID))
}}
onSessionHref={href}
>
<LocalProvider>{props.children}</LocalProvider>
+79 -41
View File
@@ -1,5 +1,18 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { batch, createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js"
import {
batch,
createEffect,
createMemo,
createRoot,
For,
Match,
on,
onCleanup,
onMount,
Show,
startTransition,
Switch,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createStore } from "solid-js/store"
import { useQuery } from "@tanstack/solid-query"
@@ -32,13 +45,12 @@ import {
displayName,
getProjectAvatarSource,
homeProjectDirectories,
homeProjectNavigation,
type HomeProjectSelection,
projectForSession,
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"
@@ -47,6 +59,8 @@ 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"
import { useMarked } from "@opencode-ai/ui/context/marked"
import { preloadMarkdown } from "@opencode-ai/ui/markdown-cache"
const HOME_SESSION_LIMIT = 64
const HOME_ROW_LAYOUT =
@@ -123,8 +137,10 @@ export function NewHome() {
const server = useServer()
const language = useLanguage()
const global = useGlobal()
const tabs = useTabs()
const command = useCommand()
const notification = useNotification()
const marked = useMarked()
let focusSessionSearch: (() => void) | undefined
const [state, setState] = createStore({
search: "",
@@ -138,7 +154,7 @@ export function NewHome() {
const focusedServerCtx = createMemo(() => {
const conn = focusedServer()
if (!conn) return
return global.createServerCtx(conn)
return global.ensureServerCtx(conn)
})
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
@@ -187,6 +203,41 @@ export function NewHome() {
})
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
const groups = createMemo(() => groupSessions(records(), language))
const prefetched = new Set<string>()
createEffect(() => {
const ctx = focusedServerCtx()
if (!ctx) return
records()
.slice(0, 2)
.forEach((record) => {
const key = `${ServerConnection.key(focusedServer()!)}\0${record.session.id}`
if (prefetched.has(key)) return
prefetched.add(key)
createRoot((dispose) => {
try {
const directory = ctx.sync.ensureDirSyncContext(record.session.directory)
void directory.session
.sync(record.session.id)
.then(() => {
const store = ctx.sync.child(record.session.directory)[0]
return Promise.all(
(store.message[record.session.id] ?? []).flatMap((message) =>
(store.part[message.id] ?? []).flatMap((part) => {
if (part.type !== "text" || !part.text) return []
return preloadMarkdown(part.text, part.id, marked)
}),
),
)
})
.catch(() => {})
.finally(dispose)
} catch {
dispose()
}
})
})
})
function setSelection(next: HomeProjectSelection) {
batch(() => {
@@ -237,7 +288,7 @@ export function NewHome() {
const key = ServerConnection.key(conn)
if (
!global
.createServerCtx(conn)
.ensureServerCtx(conn)
.projects.list()
.some((project) => project.worktree === directory)
)
@@ -248,7 +299,7 @@ export function NewHome() {
function addProjects(conn: ServerConnection.Any, directories: string[]) {
const directory = directories[0]
if (!directory) return
const ctx = global.createServerCtx(conn)
const ctx = global.ensureServerCtx(conn)
directories.forEach(ctx.projects.open)
ctx.projects.touch(directory)
setSelection({ server: ServerConnection.key(conn), directory })
@@ -261,21 +312,11 @@ export function NewHome() {
openProjectNewSession(conn, project.worktree)
}
function navigateOnServer(conn: ServerConnection.Any, href: string) {
const next = homeProjectNavigation(server.key, ServerConnection.key(conn), href)
if (!next.server) {
navigate(next.href)
return
}
pendingHomeNavigation = next
server.setActive(next.server)
}
function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
const ctx = global.createServerCtx(conn)
const ctx = global.ensureServerCtx(conn)
ctx.projects.open(directory)
ctx.projects.touch(directory)
navigateOnServer(conn, `/${base64Encode(directory)}/session`)
tabs.newDraft({ server: ServerConnection.key(conn), directory })
}
function editProject(conn: ServerConnection.Any, project: LocalProject) {
@@ -301,10 +342,19 @@ export function NewHome() {
const conn = focusedServer()
if (!conn) return
const directory = project?.worktree ?? session.directory
const ctx = global.createServerCtx(conn)
const ctx = global.ensureServerCtx(conn)
global.sessionPlacement.set({
server: ServerConnection.key(conn),
leafID: session.id,
rootID: session.id,
directory: session.directory,
})
ctx.projects.open(directory)
ctx.projects.touch(directory)
navigateOnServer(conn, `/server/${base64Encode(ServerConnection.key(conn))}/session/${session.id}`)
startTransition(() => {
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
tabs.select(tab)
})
}
function chooseProject(conn: ServerConnection.Any) {
@@ -312,8 +362,6 @@ export function NewHome() {
addProjects(conn, homeProjectDirectories(result))
}
const server = global.createServerCtx(conn)
pickDirectory({
server: conn,
title: language.t("command.project.open"),
@@ -343,7 +391,7 @@ export function NewHome() {
const next = closeHomeProject(
state.selection,
ServerConnection.key(conn),
global.createServerCtx(conn).projects,
global.ensureServerCtx(conn).projects,
directory,
)
if (next) setSelection(next)
@@ -480,7 +528,7 @@ function HomeProjectColumn(props: {
{(item) => {
const key = ServerConnection.key(item)
const healthy = () => !!global.servers.health[key]?.healthy
const serverCtx = global.createServerCtx(item)
const serverCtx = global.ensureServerCtx(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">
@@ -755,21 +803,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
@@ -787,7 +820,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>
)
}
@@ -1166,7 +1204,7 @@ export function LegacyHome() {
})
function openProject(server: ServerConnection.Any, directory: string) {
const serverCtx = global.createServerCtx(server)
const serverCtx = global.ensureServerCtx(server)
serverCtx.projects.open(directory)
serverCtx.projects.touch(directory)
navigate(`/${base64Encode(directory)}`)
+17 -4
View File
@@ -1,18 +1,25 @@
import { createEffect, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { createEffect, Suspense, type ParentProps } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useNotification } from "@/context/notification"
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 notification = useNotification()
const navigate = useNavigate()
const params = useParams<{ id?: string }>()
setNavigate(navigate)
createEffect(() => setV2Toast(true))
createEffect(() => {
if (!notification.ready() || !params.id) return
notification.session.markViewed(params.id)
})
const update: TitlebarUpdate = {
version: () => {
@@ -25,10 +32,16 @@ export default function NewLayout(props: ParentProps) {
}
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">
<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"
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
}}
>
<Titlebar update={update} />
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
{props.children}
<Suspense>{props.children}</Suspense>
</main>
{import.meta.env.DEV && <DebugBar />}
<HelpButton />
+5 -5
View File
@@ -1299,15 +1299,15 @@ export default function LegacyLayout(props: ParentProps) {
}
const openSession = async (target: { directory: string; id: string }) => {
if (!canOpen(target.directory)) return false
const [data] = serverSync().child(target.directory, { bootstrap: false })
if (data.session.some((item) => item.id === target.id)) {
const sync = serverSync().ensureDirSyncContext(target.directory)
if (sync.session.get(target.id)) {
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
return true
}
const resolved = await serverSDK()
.client.session.get({ sessionID: target.id })
.then((x) => x.data)
const resolved = await sync.session
.sync(target.id)
.then(() => sync.session.get(target.id))
.catch(() => undefined)
if (!resolved?.directory) return false
if (!canOpen(resolved.directory)) return false
@@ -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>
)
}
+27 -4
View File
@@ -287,7 +287,7 @@ export default function Page() {
})
}
return key
}, sessionKey())
})
let reviewFrame: number | undefined
let todoFrame: number | undefined
@@ -693,6 +693,7 @@ export default function Page() {
}
createEffect(() => {
if (!sync().project) return
const list = changesOptions()
if (list.includes(store.changes)) return
const next = list[0]
@@ -1128,13 +1129,35 @@ export default function Page() {
let captureHistoryAnchor = () => {}
let restoreHistoryAnchor = (_done: boolean) => {}
const loadOlder = () =>
timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
let historyRequest = false
let historyContinuationFrame: number | undefined
const loadOlder = async () => {
if (historyRequest || historyLoading()) return
historyRequest = true
const before = timeline.messages().length
try {
await timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
} finally {
historyRequest = false
}
if (timeline.messages().length <= before) return
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
historyContinuationFrame = requestAnimationFrame(() => {
historyContinuationFrame = undefined
onHistoryScroll()
})
}
const onHistoryScroll = () => {
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) return
if (historyRequest || historyLoading() || !autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200)
return
void loadOlder()
}
onCleanup(() => {
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
})
fill = () => {
if (fillFrame !== undefined) return
@@ -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,11 +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
@@ -73,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.ensureServerCtx(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
@@ -115,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,
@@ -216,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}
@@ -258,7 +291,7 @@ export function SessionComposerRegion(props: {
<Show when={showComposer()}>
<Show
when={prompt.ready()}
when={promptReadyResource()}
fallback={
<>
<Show when={rolled()} keyed>
@@ -15,37 +15,29 @@ describe("timeline model", () => {
expect(selectVisibleUserMessages(users)).toBe(users)
})
test("loads pages until a visible user turn is added", async () => {
let loaded = 10
let visible = 2
test("loads exactly one opaque cursor page", async () => {
let calls = 0
const anchors: Array<string | boolean> = []
await loadOlderTimeline({
sessionID: () => "ses_test",
loaded: () => loaded,
visible: () => visible,
more: () => true,
loading: () => false,
loadMore: async () => {
calls += 1
loaded += 3
if (calls === 2) visible += 1
},
before: () => anchors.push("before"),
after: (done) => anchors.push("after", done),
})
expect(calls).toBe(2)
expect(anchors).toEqual(["before", "after", false, "after", true])
expect(calls).toBe(1)
expect(anchors).toEqual(["before", "after", true])
})
test("stops when a page adds no raw messages", async () => {
let calls = 0
await loadOlderTimeline({
sessionID: () => "ses_test",
loaded: () => 10,
visible: () => 2,
more: () => true,
loading: () => false,
loadMore: async () => {
@@ -62,8 +54,6 @@ describe("timeline model", () => {
await loadOlderTimeline({
sessionID: () => sessionID,
loaded: () => 10,
visible: () => 2,
more: () => true,
loading: () => false,
loadMore: async () => {
@@ -83,8 +73,6 @@ describe("timeline model", () => {
await expect(
loadOlderTimeline({
sessionID: () => "ses_test",
loaded: () => 10,
visible: () => 2,
more: () => true,
loading: () => false,
loadMore: async () => {
@@ -74,8 +74,6 @@ export function createTimelineModel(input: {
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
return loadOlderTimeline({
sessionID: input.sessionID,
loaded: () => messages().length,
visible: () => visibleUserMessages().length,
more,
loading,
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
@@ -115,8 +113,6 @@ export function selectVisibleUserMessages(messages: UserMessage[], revertMessage
export async function loadOlderTimeline(input: {
sessionID: Accessor<string | undefined>
loaded: Accessor<number>
visible: Accessor<number>
more: Accessor<boolean>
loading: Accessor<boolean>
loadMore: (sessionID: string) => Promise<void>
@@ -126,23 +122,11 @@ export async function loadOlderTimeline(input: {
const id = input.sessionID()
if (!id || !input.more() || input.loading()) return
// A history page may contain only assistant messages or user turns hidden by a revert boundary.
const beforeVisible = input.visible()
let loaded = input.loaded()
input.before?.()
while (true) {
await input.loadMore(id).catch((error) => {
if (input.sessionID() === id) input.after?.(true)
throw error
})
if (input.sessionID() !== id) return
const nextLoaded = input.loaded()
const growth = input.visible() - beforeVisible
const raw = nextLoaded - loaded
loaded = nextLoaded
const done = growth > 0 || raw <= 0 || !input.more()
input.after?.(done)
if (done) return
}
await input.loadMore(id).catch((error) => {
if (input.sessionID() === id) input.after?.(true)
throw error
})
if (input.sessionID() !== id) return
input.after?.(true)
}
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { createSessionPlacementStore } from "./session-placement"
describe("session placement", () => {
const local = ServerConnection.Key.make("http://localhost:4096")
const remote = ServerConnection.Key.make("https://example.com")
test("aliases a leaf and root without crossing servers", () => {
const store = createSessionPlacementStore()
store.set({ server: local, leafID: "child", rootID: "root", directory: "/repo" })
store.set({ server: remote, leafID: "child", rootID: "other", directory: "/remote" })
expect(store.get(local, "child")).toEqual({ rootID: "root", directory: "/repo" })
expect(store.get(local, "root")).toEqual({ rootID: "root", directory: "/repo" })
expect(store.get(remote, "child")).toEqual({ rootID: "other", directory: "/remote" })
})
test("inherits known placement for in-app child navigation", () => {
const store = createSessionPlacementStore()
store.set({ server: local, leafID: "parent", rootID: "root", directory: "/repo" })
expect(store.inherit(local, "parent", "child")).toEqual({ rootID: "root", directory: "/repo" })
expect(store.get(local, "child")).toEqual({ rootID: "root", directory: "/repo" })
expect(store.inherit(local, "missing", "unknown")).toBeUndefined()
})
test("bounds retained placement aliases", () => {
const store = createSessionPlacementStore()
for (let index = 0; index < 300; index++) {
store.set({ server: local, leafID: `leaf-${index}`, rootID: `root-${index}`, directory: `/repo/${index}` })
}
expect(store.size()).toBeLessThanOrEqual(256)
expect(store.get(local, "leaf-0")).toBeUndefined()
expect(store.get(local, "leaf-299")).toEqual({ rootID: "root-299", directory: "/repo/299" })
})
})
@@ -0,0 +1,41 @@
import { ServerConnection } from "@/context/server"
export type SessionPlacement = {
rootID: string
directory: string
}
export function createSessionPlacementStore() {
const placements = new Map<string, SessionPlacement>()
const limit = 256
const key = (server: ServerConnection.Key, sessionID: string) => `${server}\0${sessionID}`
const write = (id: string, placement: SessionPlacement) => {
placements.delete(id)
placements.set(id, placement)
while (placements.size > limit) placements.delete(placements.keys().next().value!)
}
return {
get(server: ServerConnection.Key, sessionID: string) {
const id = key(server, sessionID)
const placement = placements.get(id)
if (placement) write(id, placement)
return placement
},
set(input: SessionPlacement & { server: ServerConnection.Key; leafID: string }) {
const placement = { rootID: input.rootID, directory: input.directory }
write(key(input.server, input.leafID), placement)
write(key(input.server, input.rootID), placement)
return placement
},
inherit(server: ServerConnection.Key, sourceID: string, leafID: string) {
const placement = placements.get(key(server, sourceID))
if (!placement) return
write(key(server, leafID), placement)
return placement
},
size() {
return placements.size
},
}
}
@@ -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)
}
})
})
})
})
})
+16
View File
@@ -6,6 +6,22 @@ declare const OPENCODE_CLI_NAME: string | undefined
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface",
commands: [
Spec.make("api", {
description: "Make a request to the running server",
params: {
request: Argument.string("operation | method path").pipe(
Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"),
Argument.variadic({ min: 1, max: 2 }),
),
data: Flag.string("data").pipe(Flag.withAlias("d"), Flag.withDescription("Request body"), Flag.optional),
header: Flag.string("header").pipe(
Flag.withAlias("H"),
Flag.withDescription("Request header in name:value form"),
Flag.atMost(100),
),
param: Flag.keyValuePair("param").pipe(Flag.withDescription("OpenAPI path or query parameter"), Flag.optional),
},
}),
Spec.make("debug", {
description: "Debugging and troubleshooting tools",
commands: [Spec.make("agents", { description: "List all agents" })],
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test"
import { rawRequest, resolveOperation } from "./api"
describe("api request resolution", () => {
test("resolves an operation ID with path and query parameters", () => {
expect(
resolveOperation(
{
paths: {
"/api/session/{sessionID}": {
get: { operationId: "v2.session.get" },
},
},
},
"v2.session.get",
{ sessionID: "ses/a", workspace: "work" },
),
).toEqual({ method: "GET", path: "/api/session/ses%2Fa?workspace=work" })
})
test("rejects a missing path parameter", () => {
expect(() =>
resolveOperation(
{ paths: { "/api/session/{sessionID}": { get: { operationId: "v2.session.get" } } } },
"v2.session.get",
{},
),
).toThrow("Missing path parameter: sessionID")
})
test("resolves curl-like method and path input", () => {
expect(rawRequest(["post", "/api/foo"])).toEqual({ method: "POST", path: "/api/foo" })
expect(rawRequest(["v2.session.list"])).toBeUndefined()
})
})
+85
View File
@@ -0,0 +1,85 @@
import { EOL } from "node:os"
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Daemon } from "../../services/daemon"
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
type Operation = {
operationId?: string
}
type OpenApi = {
paths?: Record<string, Record<string, Operation>>
}
export default Runtime.handler(
Commands.commands.api,
Effect.fn("cli.api")(function* (input) {
const daemon = yield* Daemon.Service
const transport = yield* daemon.transport()
const params = Option.getOrElse(input.param, () => ({}))
const request = yield* resolveRequest(transport, input.request, params)
const headers = new Headers(transport.headers)
for (const header of input.header) {
const index = header.indexOf(":")
if (index < 1) return yield* Effect.fail(new Error(`Invalid header, expected name:value: ${header}`))
headers.set(header.slice(0, index).trim(), header.slice(index + 1).trim())
}
const body = Option.getOrUndefined(input.data)
if (body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
const response = yield* Effect.tryPromise(() =>
fetch(new URL(request.path, transport.url), {
method: request.method,
headers,
body,
}),
)
const output = yield* Effect.promise(() => response.text())
if (output) process.stdout.write(output + (output.endsWith(EOL) ? "" : EOL))
}),
)
export function resolveOperation(spec: OpenApi, operationID: string, params: Record<string, string>) {
for (const [path, operations] of Object.entries(spec.paths ?? {})) {
for (const [method, operation] of Object.entries(operations)) {
if (!methods.has(method) || operation.operationId !== operationID) continue
return { method: method.toUpperCase(), path: interpolate(path, params) }
}
}
throw new Error(`Operation not found: ${operationID}`)
}
export function rawRequest(input: readonly string[]) {
if (input.length !== 2 || !methods.has(input[0].toLowerCase()) || !input[1].startsWith("/")) return
return { method: input[0].toUpperCase(), path: input[1] }
}
function resolveRequest(
transport: { url: string; headers: RequestInit["headers"] },
input: readonly string[],
params: Record<string, string>,
) {
const raw = rawRequest(input)
if (raw) return Effect.succeed(raw)
if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path"))
return Effect.tryPromise(async () => {
const response = await fetch(new URL("/openapi.json", transport.url), { headers: transport.headers })
if (!response.ok) throw new Error(`Failed to load OpenAPI document: HTTP ${response.status}`)
return resolveOperation((await response.json()) as OpenApi, input[0], params)
})
}
function interpolate(path: string, params: Record<string, string>) {
const used = new Set<string>()
const pathname = path.replaceAll(/\{([^}]+)\}/g, (_, name: string) => {
const value = params[name]
if (value === undefined) throw new Error(`Missing path parameter: ${name}`)
used.add(name)
return encodeURIComponent(value)
})
const query = new URLSearchParams(Object.entries(params).filter(([name]) => !used.has(name))).toString()
return query ? `${pathname}?${query}` : pathname
}
+1
View File
@@ -9,6 +9,7 @@ import { Daemon } from "./services/daemon"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
api: () => import("./commands/handlers/api"),
debug: {
agents: () => import("./commands/handlers/debug/agents"),
},
@@ -0,0 +1,24 @@
import type { APIEvent } from "@solidjs/start/server"
import { Referral } from "@opencode-ai/console-core/referral.js"
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
import { Resource } from "@opencode-ai/console-resource"
import z from "zod"
const Body = z.object({
inviterWorkspaceID: z.string().startsWith("wrk_"),
inviteeWorkspaceID: z.string().startsWith("wrk_"),
})
export async function POST(event: APIEvent) {
if (!safeEqual(event.request.headers.get("authorization") ?? "", `Bearer ${Resource.SUPPORT_API_KEY.value}`)) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
const body = Body.safeParse(await event.request.json().catch(() => undefined))
if (!body.success) {
return Response.json({ error: "Invalid request", issues: body.error.issues }, { status: 400 })
}
return Referral.create(body.data)
.then((result) => Response.json({ success: true, message: "Referral created", result }))
.catch((error) => Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 }))
}
@@ -0,0 +1,21 @@
import type { APIEvent } from "@solidjs/start/server"
import { Account } from "@opencode-ai/console-core/account.js"
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
import { Resource } from "@opencode-ai/console-resource"
import z from "zod"
const Body = z.object({ email: z.email() })
export async function DELETE(event: APIEvent) {
if (!safeEqual(event.request.headers.get("authorization") ?? "", `Bearer ${Resource.SUPPORT_API_KEY.value}`)) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
const body = Body.safeParse(await event.request.json().catch(() => undefined))
if (!body.success) {
return Response.json({ error: "Invalid request", issues: body.error.issues }, { status: 400 })
}
return Account.remove(body.data.email)
.then(() => Response.json({ success: true, message: "Account deleted" }))
.catch((error) => Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 }))
}
@@ -3,7 +3,7 @@ import { z } from "zod"
import { Resource } from "@opencode-ai/console-resource"
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
const DISCORD_ALERT_ROLE_ID = "1501447160175136838"
const DISCORD_ALERT_ROLE_ID = "1511795723262365887"
const basePayload = z.object({
name: z.string().optional(),
@@ -697,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
View File
@@ -37,7 +37,6 @@
"update-limits": "script/update-limits.ts",
"promote-limits-to-dev": "script/promote-limits.ts dev",
"promote-limits-to-prod": "script/promote-limits.ts production",
"referral-backfill": "script/referral-backfill.ts",
"typecheck": "tsgo --noEmit"
},
"devDependencies": {
@@ -1,153 +0,0 @@
import { and, Database, eq, inArray, isNull } from "../src/drizzle/index.js"
import { Identifier } from "../src/identifier.js"
import { Referral } from "../src/referral.js"
import { LiteTable } from "../src/schema/billing.sql.js"
import { ReferralRewardTable, ReferralTable } from "../src/schema/referral.sql.js"
import { UserTable } from "../src/schema/user.sql.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
const backfills = [
{
inviterWorkspaceID: "wrk_00000000000000000000000000",
inviteeWorkspaceID: "wrk_00000000000000000000000000",
inviteeAccountID: "acc_00000000000000000000000000",
},
]
console.log(`Backfilling ${backfills.length} referrals`)
for (const [index, backfill] of backfills.entries()) {
console.log(`[${index + 1}/${backfills.length}] ${backfill.inviterWorkspaceID} -> ${backfill.inviteeWorkspaceID}`)
console.log(` invitee account: ${backfill.inviteeAccountID}`)
const result = await Database.transaction(async (tx) => {
if (backfill.inviterWorkspaceID === backfill.inviteeWorkspaceID) throw new Error("Self-referral workspace mismatch")
const inviterWorkspace = await tx
.select({ id: WorkspaceTable.id })
.from(WorkspaceTable)
.where(and(eq(WorkspaceTable.id, backfill.inviterWorkspaceID), isNull(WorkspaceTable.timeDeleted)))
.then((rows) => rows[0])
if (!inviterWorkspace) throw new Error(`Inviter workspace not found: ${backfill.inviterWorkspaceID}`)
const inviteeWorkspace = await tx
.select({ id: WorkspaceTable.id })
.from(WorkspaceTable)
.where(and(eq(WorkspaceTable.id, backfill.inviteeWorkspaceID), isNull(WorkspaceTable.timeDeleted)))
.then((rows) => rows[0])
if (!inviteeWorkspace) throw new Error(`Invitee workspace not found: ${backfill.inviteeWorkspaceID}`)
const inviteeUser = await tx
.select({ id: UserTable.id })
.from(UserTable)
.where(
and(
eq(UserTable.workspaceID, backfill.inviteeWorkspaceID),
eq(UserTable.accountID, backfill.inviteeAccountID),
eq(UserTable.role, "admin"),
isNull(UserTable.timeDeleted),
),
)
.then((rows) => rows[0])
if (!inviteeUser) throw new Error(`Invitee workspace owner not found: ${backfill.inviteeAccountID}`)
const inviterUser = await tx
.select({ id: UserTable.id })
.from(UserTable)
.where(
and(
eq(UserTable.workspaceID, backfill.inviterWorkspaceID),
eq(UserTable.accountID, backfill.inviteeAccountID),
isNull(UserTable.timeDeleted),
),
)
.then((rows) => rows[0])
if (inviterUser) throw new Error(`Self-referral is not allowed: ${backfill.inviteeAccountID}`)
const lite = await tx
.select({ id: LiteTable.id })
.from(LiteTable)
.where(
and(
eq(LiteTable.workspaceID, backfill.inviteeWorkspaceID),
eq(LiteTable.userID, inviteeUser.id),
isNull(LiteTable.timeDeleted),
),
)
.then((rows) => rows[0])
if (!lite) throw new Error(`Invitee Lite subscription not found: ${backfill.inviteeWorkspaceID}`)
const existingReferral = await tx
.select({ id: ReferralTable.id, workspaceID: ReferralTable.workspaceID })
.from(ReferralTable)
.where(and(eq(ReferralTable.inviteeAccountID, backfill.inviteeAccountID), isNull(ReferralTable.timeDeleted)))
.then((rows) => rows[0])
if (existingReferral && existingReferral.workspaceID !== backfill.inviterWorkspaceID) {
throw new Error(`Referral already belongs to ${existingReferral.workspaceID}: ${existingReferral.id}`)
}
const referralID = existingReferral?.id ?? Identifier.create("referral")
if (!existingReferral) {
await tx.insert(ReferralTable).ignore().values({
workspaceID: backfill.inviterWorkspaceID,
id: referralID,
inviteeAccountID: backfill.inviteeAccountID,
})
const referral = await tx
.select({ id: ReferralTable.id })
.from(ReferralTable)
.where(and(eq(ReferralTable.inviteeAccountID, backfill.inviteeAccountID), isNull(ReferralTable.timeDeleted)))
.then((rows) => rows[0])
if (!referral) throw new Error(`Referral not created: ${backfill.inviteeAccountID}`)
if (referral.id !== referralID) throw new Error(`Referral already redeemed: ${referral.id}`)
}
const rewardInsert = await tx
.insert(ReferralRewardTable)
.ignore()
.values([
{
workspaceID: backfill.inviterWorkspaceID,
referralID,
amount: Referral.REWARD_AMOUNT,
},
{
workspaceID: backfill.inviteeWorkspaceID,
referralID,
amount: Referral.REWARD_AMOUNT,
},
])
const rewards = await tx
.select({ workspaceID: ReferralRewardTable.workspaceID, amount: ReferralRewardTable.amount })
.from(ReferralRewardTable)
.where(
and(
eq(ReferralRewardTable.referralID, referralID),
inArray(ReferralRewardTable.workspaceID, [backfill.inviterWorkspaceID, backfill.inviteeWorkspaceID]),
isNull(ReferralRewardTable.timeDeleted),
),
)
if (rewards.length !== 2) throw new Error(`Referral rewards not created: ${referralID}`)
if (rewards.some((reward) => reward.amount !== Referral.REWARD_AMOUNT)) {
throw new Error(`Referral reward amount mismatch: ${referralID}`)
}
return {
referralID,
createdReferral: !existingReferral,
createdRewards: rewardInsert.rowsAffected,
inviteeUserID: inviteeUser.id,
liteID: lite.id,
rewardWorkspaces: rewards.map((reward) => reward.workspaceID),
}
})
console.log(` invitee user: ${result.inviteeUserID}`)
console.log(` lite: ${result.liteID}`)
console.log(` referral: ${result.referralID} (${result.createdReferral ? "created" : "existing"})`)
console.log(` rewards: ${result.rewardWorkspaces.join(", ")} (${result.createdRewards} inserted)`)
}
console.log("Referral backfill complete")
+51 -1
View File
@@ -1,9 +1,13 @@
import { z } from "zod"
import { eq } from "drizzle-orm"
import { and, eq, inArray, sql } from "drizzle-orm"
import { fn } from "./util/fn"
import { Database } from "./drizzle"
import { Identifier } from "./identifier"
import { AccountTable } from "./schema/account.sql"
import { AuthTable } from "./schema/auth.sql"
import { UserTable } from "./schema/user.sql"
import { KeyTable } from "./schema/key.sql"
import { CouponTable } from "./schema/billing.sql"
export namespace Account {
export const create = fn(
@@ -20,6 +24,52 @@ export namespace Account {
}),
)
export const remove = fn(z.email(), async (email) => {
await Database.transaction(async (tx) => {
const account = await tx
.select({ id: AccountTable.id })
.from(AuthTable)
.innerJoin(AccountTable, eq(AccountTable.id, AuthTable.accountID))
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email)))
.then((rows) => rows[0])
if (!account) throw new Error("Account not found")
const emails = await tx
.select({ email: AuthTable.subject })
.from(AuthTable)
.where(and(eq(AuthTable.accountID, account.id), eq(AuthTable.provider, "email")))
const users = await tx.select({ id: UserTable.id }).from(UserTable).where(eq(UserTable.accountID, account.id))
if (users.length > 0) {
await tx
.update(KeyTable)
.set({ timeDeleted: sql`now()` })
.where(
inArray(
KeyTable.userID,
users.map((user) => user.id),
),
)
}
await tx
.update(UserTable)
.set({ accountID: null, email: null, name: "", timeDeleted: sql`now()` })
.where(eq(UserTable.accountID, account.id))
if (emails.length > 0) {
await tx.delete(CouponTable).where(
inArray(
CouponTable.email,
emails.map((row) => row.email),
),
)
}
await tx.delete(AuthTable).where(eq(AuthTable.accountID, account.id))
await tx
.update(AccountTable)
.set({ timeDeleted: sql`now()` })
.where(eq(AccountTable.id, account.id))
})
})
export const fromID = fn(z.string(), async (id) =>
Database.use((tx) =>
tx
+113
View File
@@ -362,6 +362,119 @@ export namespace Referral {
})
}
export async function create(input: { inviterWorkspaceID: string; inviteeWorkspaceID: string }) {
return Database.transaction(async (tx) => {
if (input.inviterWorkspaceID === input.inviteeWorkspaceID) throw new Error("Self-referral workspace mismatch")
const inviterWorkspace = await tx
.select({ id: WorkspaceTable.id })
.from(WorkspaceTable)
.where(and(eq(WorkspaceTable.id, input.inviterWorkspaceID), isNull(WorkspaceTable.timeDeleted)))
.then((rows) => rows[0])
if (!inviterWorkspace) throw new Error(`Inviter workspace not found: ${input.inviterWorkspaceID}`)
const inviteeWorkspace = await tx
.select({ id: WorkspaceTable.id })
.from(WorkspaceTable)
.where(and(eq(WorkspaceTable.id, input.inviteeWorkspaceID), isNull(WorkspaceTable.timeDeleted)))
.then((rows) => rows[0])
if (!inviteeWorkspace) throw new Error(`Invitee workspace not found: ${input.inviteeWorkspaceID}`)
const invitee = await tx
.select({ accountID: UserTable.accountID, userID: UserTable.id, liteID: LiteTable.id })
.from(UserTable)
.innerJoin(
LiteTable,
and(
eq(LiteTable.workspaceID, UserTable.workspaceID),
eq(LiteTable.userID, UserTable.id),
isNull(LiteTable.timeDeleted),
),
)
.where(
and(
eq(UserTable.workspaceID, input.inviteeWorkspaceID),
eq(UserTable.role, "admin"),
isNull(UserTable.timeDeleted),
),
)
.then((rows) => rows[0])
if (!invitee?.accountID) throw new Error(`Invitee Lite workspace owner not found: ${input.inviteeWorkspaceID}`)
const inviterUser = await tx
.select({ id: UserTable.id })
.from(UserTable)
.where(
and(
eq(UserTable.workspaceID, input.inviterWorkspaceID),
eq(UserTable.accountID, invitee.accountID),
isNull(UserTable.timeDeleted),
),
)
.then((rows) => rows[0])
if (inviterUser) throw new Error(`Self-referral is not allowed: ${invitee.accountID}`)
const existingReferral = await tx
.select({ id: ReferralTable.id, workspaceID: ReferralTable.workspaceID })
.from(ReferralTable)
.where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
.then((rows) => rows[0])
if (existingReferral && existingReferral.workspaceID !== input.inviterWorkspaceID) {
throw new Error(`Referral already belongs to ${existingReferral.workspaceID}: ${existingReferral.id}`)
}
const referralID = existingReferral?.id ?? Identifier.create("referral")
if (!existingReferral) {
await tx.insert(ReferralTable).ignore().values({
workspaceID: input.inviterWorkspaceID,
id: referralID,
inviteeAccountID: invitee.accountID,
})
const referral = await tx
.select({ id: ReferralTable.id })
.from(ReferralTable)
.where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
.then((rows) => rows[0])
if (!referral) throw new Error(`Referral not created: ${invitee.accountID}`)
if (referral.id !== referralID) throw new Error(`Referral already redeemed: ${referral.id}`)
}
const rewardInsert = await tx
.insert(ReferralRewardTable)
.ignore()
.values([
{ workspaceID: input.inviterWorkspaceID, referralID, amount: REWARD_AMOUNT },
{ workspaceID: input.inviteeWorkspaceID, referralID, amount: REWARD_AMOUNT },
])
const rewards = await tx
.select({ workspaceID: ReferralRewardTable.workspaceID, amount: ReferralRewardTable.amount })
.from(ReferralRewardTable)
.where(
and(
eq(ReferralRewardTable.referralID, referralID),
inArray(ReferralRewardTable.workspaceID, [input.inviterWorkspaceID, input.inviteeWorkspaceID]),
isNull(ReferralRewardTable.timeDeleted),
),
)
if (rewards.length !== 2) throw new Error(`Referral rewards not created: ${referralID}`)
if (rewards.some((reward) => reward.amount !== REWARD_AMOUNT)) {
throw new Error(`Referral reward amount mismatch: ${referralID}`)
}
return {
referralID,
createdReferral: !existingReferral,
createdRewards: rewardInsert.rowsAffected,
inviteeAccountID: invitee.accountID,
inviteeUserID: invitee.userID,
liteID: invitee.liteID,
rewardWorkspaces: rewards.map((reward) => reward.workspaceID),
}
})
}
export async function completeFromLiteSubscription(input: { workspaceID: string; userID: string }) {
return Database.transaction(async (tx) => {
const invitee = await tx
+8
View File
@@ -11,6 +11,7 @@ import { Key } from "./key"
import { KeyTable } from "./schema/key.sql"
import { WorkspaceTable } from "./schema/workspace.sql"
import { AuthTable } from "./schema/auth.sql"
import { AccountTable } from "./schema/account.sql"
export namespace User {
const assertNotSelf = (id: string) => {
@@ -161,6 +162,13 @@ export namespace User {
export const joinInvitedWorkspaces = fn(z.void(), async () => {
const account = Actor.assert("account")
const invitations = await Database.use(async (tx) => {
const active = await tx
.select({ id: AccountTable.id })
.from(AccountTable)
.where(and(eq(AccountTable.id, account.properties.accountID), isNull(AccountTable.timeDeleted)))
.then((rows) => rows[0])
if (!active) throw new Error("Account is not active")
const invitations = await tx
.select({
id: UserTable.id,
+9 -1
View File
@@ -6,8 +6,9 @@ import { Identifier } from "./identifier"
import { UserTable } from "./schema/user.sql"
import { BillingTable } from "./schema/billing.sql"
import { WorkspaceTable } from "./schema/workspace.sql"
import { AccountTable } from "./schema/account.sql"
import { Key } from "./key"
import { eq, sql } from "drizzle-orm"
import { and, eq, isNull, sql } from "drizzle-orm"
export namespace Workspace {
export const create = fn(
@@ -19,6 +20,13 @@ export namespace Workspace {
const workspaceID = Identifier.create("workspace")
const userID = Identifier.create("user")
await Database.transaction(async (tx) => {
const active = await tx
.select({ id: AccountTable.id })
.from(AccountTable)
.where(and(eq(AccountTable.id, account.properties.accountID), isNull(AccountTable.timeDeleted)))
.then((rows) => rows[0])
if (!active) throw new Error("Account is not active")
await tx.insert(WorkspaceTable).values({
id: workspaceID,
name,
@@ -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"),
+1
View File
@@ -91,6 +91,7 @@
"@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",
+6 -35
View File
@@ -1,46 +1,17 @@
export * as AgentV2 from "./agent"
import { Array, Context, Effect, Layer, Schema, Scope, Types } from "effect"
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
+3 -3
View File
@@ -73,7 +73,7 @@ export const layer = Layer.effect(
if (provider.disabled) return false
if (typeof provider.request.body.apiKey === "string") return true
if (integration?.connections.length) return true
return !integration
return provider.integrationID === undefined && !integration
}
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
@@ -89,7 +89,7 @@ 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,
@@ -184,7 +184,7 @@ export const layer = Layer.effect(
available: Effect.fn("CatalogV2.provider.available")(function* () {
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
return (yield* result.provider.all()).filter((provider) =>
available(provider, active.get(Integration.ID.make(provider.id))),
available(provider, active.get(provider.integrationID ?? Integration.ID.make(provider.id))),
)
}),
},
+5 -11
View File
@@ -1,17 +1,11 @@
export * as CommandV2 from "./command"
import { Context, Effect, Layer, Schema, Types } from "effect"
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, Types.DeepMutable<Info>>
@@ -40,7 +34,7 @@ export const layer = Layer.effect(
list: () => Array.from(draft.commands.values()) as Info[],
get: (name) => draft.commands.get(name),
update: (name, update) => {
const current = draft.commands.get(name) ?? (new Info({ name, template: "" }) as Types.DeepMutable<Info>)
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
+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),
}) {}
+2 -2
View File
@@ -28,7 +28,7 @@ export const Plugin = define({
entries.set(
name,
local(entry)
? new Reference.LocalSource({
? Reference.LocalSource.make({
type: "local",
path: AbsolutePath.make(
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
@@ -36,7 +36,7 @@ export const Plugin = define({
description: typeof entry === "string" ? undefined : entry.description,
hidden: typeof entry === "string" ? undefined : entry.hidden,
})
: new Reference.GitSource({
: Reference.GitSource.make({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
branch: typeof entry === "string" ? undefined : entry.branch,
+7 -4
View File
@@ -22,20 +22,23 @@ export const Plugin = define({
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
for (const directory of directories) {
draft.source(
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
)
draft.source(
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
draft.source(new SkillV2.UrlSource({ type: "url", url: item }))
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
continue
}
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
draft.source(
new SkillV2.DirectorySource({
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
+13 -28
View File
@@ -2,41 +2,26 @@ 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 Value = Schema.Union([OAuth, Key])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Credential.Value" })
export type Value = Schema.Schema.Type<typeof Value>
export const Value = Credential.Value
export type Value = Credential.Value
export class Info extends Schema.Class<Info>("Credential.Info")({
id: ID,
integrationID: IntegrationSchema.ID,
integrationID: Integration.ID,
label: Schema.String,
value: Value,
}) {}
@@ -45,12 +30,12 @@ export interface Interface {
/** Returns every stored credential. */
readonly all: () => Effect.Effect<Info[]>
/** Returns stored credentials belonging to one integration. */
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Info[]>
readonly list: (integrationID: Integration.ID) => Effect.Effect<Info[]>
/** Returns one stored credential by ID. */
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 integrationID: Integration.ID
readonly value: Value
readonly label?: string
}) => Effect.Effect<Info>
+1 -2
View File
@@ -1,11 +1,10 @@
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.Value>().notNull(),
connector_id: text(),
+1 -2
View File
@@ -5,7 +5,7 @@ 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, withStatics } from "./schema"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { LayerNode } from "./effect/layer-node"
import { isDeepStrictEqual } from "node:util"
@@ -14,7 +14,6 @@ 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
+3 -4
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),
}),
]
})
-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),
}) {}
+20 -30
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),
})
})
}),
+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. */
+72 -76
View File
@@ -14,19 +14,19 @@ import {
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"),
@@ -34,64 +34,29 @@ 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.mutable(
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.mutable(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.mutable(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,
@@ -100,7 +65,8 @@ export class Info extends Schema.Class<Info>("Integration.Info")({
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
@@ -108,11 +74,11 @@ export type OAuthAuthorization = {
} & (
| {
readonly mode: "auto"
readonly callback: Effect.Effect<Credential.Value, unknown>
readonly callback: Effect.Effect<Credential.OAuth, unknown>
}
| {
readonly mode: "code"
readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
readonly callback: (code: string) => Effect.Effect<Credential.OAuth, unknown>
}
)
@@ -121,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 {
@@ -135,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,
@@ -178,12 +141,14 @@ 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: Types.DeepMutable<Ref>
@@ -215,7 +180,11 @@ export interface Interface extends State.Transformable<Draft> {
readonly list: () => Effect.Effect<Info[]>
readonly connection: {
/** 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. */
@@ -385,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.Value, 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)
@@ -397,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)
@@ -445,10 +413,29 @@ export const locationLayer = Layer.effect(
).toSorted((a, b) => a.name.localeCompare(b.name))
}),
connection: {
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
active: Effect.fn("Integration.connection.active")(function* (id) {
const entry = state.get().integrations.get(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
.get()
@@ -458,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) {
@@ -503,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 -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,
+5 -27
View File
@@ -1,34 +1,12 @@
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>
+14 -103
View File
@@ -1,119 +1,30 @@
import { Schema, Types } from "effect"
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, Schema.mutable),
output: Schema.String.pipe(Schema.Array, Schema.mutable),
})
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, Schema.mutable),
time: Schema.Struct({
released: Schema.Finite,
}),
cost: Cost.pipe(Schema.Array, Schema.mutable),
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: 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>
+11 -14
View File
@@ -1,6 +1,7 @@
export * as PermissionV2 from "./permission"
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { EventV2 } from "./event"
import { Location } from "./location"
import { AgentV2 } from "./agent"
@@ -9,14 +10,10 @@ import { SessionStore } from "./session/store"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { Wildcard } from "./util/wildcard"
import { PermissionSchema } from "./permission/schema"
import { PermissionSaved } from "./permission/saved"
export { Effect, Rule, Ruleset } from "./permission/schema"
type Effect = PermissionSchema.Effect
type Rule = PermissionSchema.Rule
type Ruleset = PermissionSchema.Ruleset
const missingAgentPermissions: Ruleset = [{ action: "*", resource: "*", effect: "deny" }]
export { Effect, Rule, Ruleset } from "@opencode-ai/schema/permission"
const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }]
export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
Schema.brand("PermissionV2.ID"),
@@ -67,7 +64,7 @@ export type ReplyInput = typeof ReplyInput.Type
export const AskResult = Schema.Struct({
id: ID,
effect: PermissionSchema.Effect,
effect: Permission.Effect,
}).annotate({ identifier: "PermissionV2.AskResult" })
export type AskResult = typeof AskResult.Type
@@ -90,7 +87,7 @@ export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("P
}) {}
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionV2.DeniedError", {
rules: PermissionSchema.Ruleset,
rules: Permission.Ruleset,
}) {}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PermissionV2.NotFoundError", {
@@ -99,7 +96,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Per
export type Error = DeniedError | RejectedError | CorrectedError
export function evaluate(action: string, resource: string, ...rulesets: Ruleset[]): Rule {
export function evaluate(action: string, resource: string, ...rulesets: Permission.Ruleset[]): Permission.Rule {
return (
rulesets
.flat()
@@ -111,7 +108,7 @@ export function evaluate(action: string, resource: string, ...rulesets: Ruleset[
)
}
export function merge(...rulesets: Ruleset[]): Ruleset {
export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
return rulesets.flat()
}
@@ -156,7 +153,7 @@ export const layer = Layer.effect(
const savedRules = EffectRuntime.fnUntraced(function* () {
return (yield* saved.list({ projectID: location.project.id })).map(
(item): Rule => ({ action: item.action, resource: item.resource, effect: "allow" }),
(item): Permission.Rule => ({ action: item.action, resource: item.resource, effect: "allow" }),
)
})
@@ -170,11 +167,11 @@ export const layer = Layer.effect(
return agent?.permissions ?? missingAgentPermissions
})
function denied(input: AssertInput, rules: Ruleset) {
function denied(input: AssertInput, rules: Permission.Ruleset) {
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
}
function relevant(input: AssertInput, rules: Ruleset) {
function relevant(input: AssertInput, rules: Permission.Ruleset) {
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
}
@@ -183,7 +180,7 @@ export const layer = Layer.effect(
if (denied(input, rules)) return { effect: "deny" as const, rules }
const all = [...rules, ...(yield* savedRules())]
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effect: Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
return { effect, rules: all }
})

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