Compare commits

...

60 Commits

Author SHA1 Message Date
Adam 44af3d0a17 feat(stats): add i18n support 2026-06-24 13:25:16 -05:00
opencode-agent[bot] 31b58b4704 chore: generate 2026-06-24 17:21:26 +00:00
Dax Raad 42bb793574 feat(core): generate model variants 2026-06-24 13:17:14 -04:00
Dax Raad 17f312d537 refactor(core): simplify model requests 2026-06-24 13:17:14 -04:00
Aiden Cline 116ac93ddb fix(mcp): hide denied resource template tool (#33686) 2026-06-24 12:01:44 -05:00
opencode-agent[bot] 2684293291 chore: generate 2026-06-24 16:56:33 +00:00
4rcadia e8e83afbce feat(mcp): append server instructions to context (#32490)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-24 11:54:19 -05:00
Adam 2e909334c1 refactor(stats): simplify locale prefix stripping 2026-06-24 10:33:59 -05:00
Adam 69f3b633b8 fix(stats): strip locale prefixes from data routes 2026-06-24 10:33:58 -05:00
Frank db7c28b3ee zen: migrate to new inference 2026-06-24 11:15:42 -04:00
opencode-agent[bot] e45f55d922 chore: update nix node_modules hashes 2026-06-24 15:09:00 +00:00
Victor Navarro 04c1730c90 refactor(ui): isolate session components (#33670) 2026-06-24 14:56:09 +00:00
opencode-agent[bot] 1ef0fd5d01 chore: generate 2026-06-24 13:59:28 +00:00
Shoubhit Dash 36501a81c3 docs(llm): propose AI library design (#33666) 2026-06-24 19:27:21 +05:30
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
430 changed files with 12557 additions and 4083 deletions
+2
View File
@@ -9,6 +9,7 @@ on:
- "bun.lock"
- "packages/storybook/**"
- "packages/ui/**"
- "packages/session-ui/**"
pull_request:
branches: [dev]
paths:
@@ -17,6 +18,7 @@ on:
- "bun.lock"
- "packages/storybook/**"
- "packages/ui/**"
- "packages/session-ui/**"
workflow_dispatch:
concurrency:
-9
View File
@@ -57,13 +57,6 @@ The bounded projection of a Core-executed tool result persisted in Session histo
**Managed Tool Output File**:
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
**Model Request Options**:
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
_Avoid_: Request body, wire options
**Generation Controls**:
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
**PTY Environment**:
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
@@ -114,8 +107,6 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
+84 -22
View File
@@ -34,6 +34,7 @@
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
@@ -277,6 +278,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",
@@ -416,6 +418,7 @@
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/diffs": "catalog:",
"@solidjs/meta": "catalog:",
@@ -480,6 +483,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 +645,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 +655,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": {
@@ -690,6 +705,50 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.17.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"diff": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:",
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"morphdom": "2.7.8",
"motion": "12.34.5",
"remeda": "catalog:",
"remend": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
"solid-list": "catalog:",
"strip-ansi": "7.1.2",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/katex": "0.16.7",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
},
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.9",
@@ -776,6 +835,7 @@
"packages/storybook": {
"name": "@opencode-ai/storybook",
"devDependencies": {
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
"@storybook/addon-a11y": "^10.2.13",
@@ -827,8 +887,6 @@
"version": "1.17.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:",
"@shikijs/transformers": "3.9.2",
@@ -947,9 +1005,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,12 +1862,16 @@
"@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"],
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
"@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"],
@@ -1854,27 +1916,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 +3114,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 +5472,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=="],
@@ -5830,6 +5890,8 @@
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
+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"),
}
+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-cXEEfBfOY7umTADufUAgFdwwq8tfFWDOS1+6rsHeC3o=",
"aarch64-linux": "sha256-xX9IutXeIEdj0IuW5ohXQfcK7ovMZ7bY0mIAwJXc5pU=",
"aarch64-darwin": "sha256-9iXck/18RsR+ncVUL0e2miccZz1mEde9ddVvsg6L29A=",
"x86_64-darwin": "sha256-lFO/GpThzUw1uymb9EllJdg5ZTW/iaJf/6LaXzL/1y0="
}
}
+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": "*",
+1
View File
@@ -47,6 +47,7 @@
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
+178 -184
View File
@@ -4,13 +4,13 @@ import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/ui/file"
import { File } from "@opencode-ai/session-ui/file"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { 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,123 +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) =>
search.draftId ? resolvedDirectory() : (prev ?? resolvedDirectory()),
)
const home = () => !params.serverKey && !search.draftId
const targetDirectory = () => directory()!
createEffect(() => {
const current = resolved.data
const key = serverKey()
if (!current || !key) return
tabs.addSessionTab({
server: key,
sessionId: current.rootID,
})
})
return (
<NewServerScopedShell directory={() => (home() ? undefined : directory())} sessionID={() => params.id}>
<Show when={!home()} fallback={props.children}>
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
<Show when={directory()}>
<Show
when={!params.serverKey || settings.general.newLayoutDesigns()}
fallback={<Navigate href={legacySessionHref(directory()!, params.id!)} />}
>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<Show when={!params.serverKey || (resolved.data && !resolved.isPlaceholderData)}>
{props.children}
</Show>
</DirectoryDataProvider>
</SDKProvider>
</Show>
</Show>
</Show>
</Show>
</NewServerScopedShell>
)
}
function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
return (
<Show when={tabs.ready()}>
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
<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>
)
}
@@ -304,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
@@ -332,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>
)
}
@@ -522,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>
@@ -550,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>
)}
>
@@ -576,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} />
</>
)
}
@@ -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))
}
@@ -7,8 +7,8 @@ import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/ui/file"
import { Markdown } from "@opencode-ai/ui/markdown"
import { File } from "@opencode-ai/session-ui/file"
import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
@@ -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}>
+101 -41
View File
@@ -2,6 +2,7 @@ import {
createEffect,
createMemo,
createResource,
createRoot,
createSignal,
For,
Match,
@@ -39,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>
@@ -99,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
@@ -244,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()
@@ -260,7 +256,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<Switch>
<Match when={useV2Titlebar()}>
{(_) => {
const serverSdk = useServerSDK()
const navigate = useNavigate()
const layout = useLayout()
const global = useGlobal()
@@ -271,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(() => {}),
)
@@ -343,7 +342,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}
const fallback = global.servers.list().flatMap((conn) => {
const project = global.createServerCtx(conn).projects.list()[0]
const project = global.ensureServerCtx(conn).projects.list()[0]
return project ? [{ server: ServerConnection.key(conn), project }] : []
})[0]
if (!fallback) return
@@ -419,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)
})
@@ -498,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 && (
@@ -523,20 +507,76 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
)
}
const [session] = createResource(
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 conn = server.list.find((s) => ServerConnection.key(s) === tab.server)
if (!conn) return null
const { sdk } = global.createServerCtx(conn)
return { id, sdk }
const ctx = serverCtx()
return ctx ? { id, ctx } : null
},
({ id, sdk }) =>
sdk.client.session
({ id, ctx }) =>
ctx.sdk.client.session
.get({ sessionID: id })
.then((x) => x.data)
.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 (
<>
@@ -835,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
@@ -857,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
@@ -937,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)
@@ -982,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>>()
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query"
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
import type { State, VcsCache } from "./types"
import { ServerScope } from "@/utils/server-scope"
@@ -19,7 +19,7 @@ import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
type GlobalStore = {
@@ -1,7 +1,7 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
import { createRoot, getOwner, type Owner } from "solid-js"
import { createStore } from "solid-js/store"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { State } from "./types"
import type { QueryOptionsApi } from "../server-sync"
import { ServerScope } from "@/utils/server-scope"
@@ -17,7 +17,7 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQuery } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync"
import { directoryKey, type DirectoryKey } from "./utils"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { ServerScope } from "@/utils/server-scope"
export function createChildStoreManager(input: {
@@ -15,7 +15,7 @@ import type {
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
@@ -1,5 +1,5 @@
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
+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)
}
+32 -1
View File
@@ -8,6 +8,10 @@ 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
@@ -258,14 +262,23 @@ export function createPromptState() {
}
}
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 = () => {
@@ -288,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) {
+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
})
},
})
+4 -3
View File
@@ -29,7 +29,8 @@ import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
@@ -509,7 +510,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 +532,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 }
+8
View File
@@ -1,4 +1,5 @@
@import "@opencode-ai/ui/styles/tailwind";
@import "@opencode-ai/session-ui/styles";
@import "@opencode-ai/ui/v2/styles/tailwind.css";
@font-face {
@@ -15,6 +16,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% {
+8 -2
View File
@@ -1,4 +1,4 @@
import { DataProvider } from "@opencode-ai/ui/context"
import { DataProvider } from "@opencode-ai/session-ui/context"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
@@ -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>
+72 -24
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,7 +45,6 @@ import {
displayName,
getProjectAvatarSource,
homeProjectDirectories,
homeProjectNavigation,
type HomeProjectSelection,
projectForSession,
sortedRootSessions,
@@ -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/session-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">
@@ -1156,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)}`)
+15 -2
View File
@@ -1,18 +1,25 @@
import { createEffect, Suspense, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router"
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,7 +32,13 @@ 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">
<Suspense>{props.children}</Suspense>
+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
@@ -3,7 +3,7 @@ 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 { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
import { Show } from "solid-js"
export function SessionTabAvatar(props: {
+28 -5
View File
@@ -25,7 +25,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select"
import { Tabs } from "@opencode-ai/ui/tabs"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
@@ -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
@@ -91,7 +91,7 @@ export function SessionComposerRegion(props: {
})
const projectServerCtx = createMemo(() => {
const conn = projectServer()
if (conn) return global.createServerCtx(conn)
if (conn) return global.ensureServerCtx(conn)
})
const projects = createMemo(() =>
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
@@ -1,7 +1,7 @@
import { For, Show } from "solid-js"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
@@ -2,7 +2,7 @@ import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid
import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@/utils/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
+3 -3
View File
@@ -2,10 +2,10 @@ import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch }
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileSearchHandle } from "@opencode-ai/ui/file"
import type { FileSearchHandle } from "@opencode-ai/session-ui/file"
import { useFileComponent } from "@opencode-ai/ui/context/file"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { createLineCommentController } from "@opencode-ai/ui/line-comment-annotations"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations"
import { sampledChecksum } from "@opencode-ai/core/util/encode"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -1,12 +1,12 @@
import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { SessionReview } from "@opencode-ai/session-ui/session-review"
import type {
SessionReviewCommentActions,
SessionReviewCommentDelete,
SessionReviewCommentUpdate,
} from "@opencode-ai/ui/session-review"
} from "@opencode-ai/session-ui/session-review"
import type { SelectedLineRange } from "@/context/file"
import { useSDK } from "@/context/sdk"
import { useLayout } from "@/context/layout"
@@ -26,7 +26,7 @@ import {
Part as MessagePart,
partDefaultOpen,
type UserActions,
} from "@opencode-ai/ui/message-part"
} from "@opencode-ai/session-ui/message-part"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
@@ -35,7 +35,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Spinner } from "@opencode-ai/ui/spinner"
import { SessionRetry } from "@opencode-ai/ui/session-retry"
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { TextField } from "@opencode-ai/ui/text-field"
@@ -51,7 +51,7 @@ import type {
import { showToast } from "@/utils/toast"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { normalize } from "@opencode-ai/ui/session-diff"
import { normalize } from "@opencode-ai/session-ui/session-diff"
import { useFileComponent } from "@opencode-ai/ui/context/file"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
@@ -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)
}
@@ -1,6 +1,6 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
import { groupParts, PartGroup, renderable } from "@opencode-ai/ui/message-part"
import { groupParts, PartGroup, renderable } from "@opencode-ai/session-ui/message-part"
import { Data, Equal } from "effect"
export type SummaryDiff = SnapshotFileDiff & { file: string }
@@ -1,7 +1,7 @@
import { useNavigate } from "@solidjs/router"
import { useCommand, type CommandOption } from "@/context/command"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
@@ -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
},
}
}
@@ -20,6 +20,8 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useParams: () => ({}),
useSearchParams: () => [{}],
useLocation: () => ({ pathname: "", query: {} }),
useNavigate: () => () => undefined,
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
+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(),
@@ -196,6 +196,7 @@ export async function handler(
providerInfo.modifyHeaders(headers, providerInfo.apiKey, stickyId)
Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => {
if (v === "$ip") return headers.set(k, ip)
if (v === "$caller") return headers.set(k, `caller:${ip}`)
if (v === "$session") return headers.set(k, sessionId)
if (v === "$model") return headers.set(k, model)
if (v === "$request") return headers.set(k, requestId)
@@ -354,7 +355,7 @@ export async function handler(
responseLength += value.length
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split(providerInfo.streamSeparator)
const parts = buffer.split(/\r\n\r\n|\n\n|\r\r/)
buffer = parts.pop() ?? ""
for (let part of parts) {
@@ -139,7 +139,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
return encoder.encode(messages.join(""))
}
},
streamSeparator: "\n\n",
createUsageParser: () => {
let usage: Usage
@@ -37,7 +37,6 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
return body
},
createBinaryStreamDecoder: () => undefined,
streamSeparator: "\r\n\r\n",
createUsageParser: () => {
let usage: Usage
@@ -37,7 +37,6 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
}
},
createBinaryStreamDecoder: () => undefined,
streamSeparator: "\n\n",
createUsageParser: () => {
let usage: Usage
@@ -20,7 +20,6 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
},
modifyBody: (body: Record<string, any>) => body,
createBinaryStreamDecoder: () => undefined,
streamSeparator: "\n\n",
createUsageParser: () => {
let usage: Usage
@@ -44,7 +44,6 @@ export type ProviderHelper = (input: {
modifyHeaders: (headers: Headers, apiKey: string, stickyId: string) => void
modifyBody: (body: Record<string, any>) => Record<string, any>
createBinaryStreamDecoder: () => ((chunk: Uint8Array) => Uint8Array | undefined) | undefined
streamSeparator: string
createUsageParser: () => {
parse: (chunk: string) => void
retrieve: () => any
-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,
+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
+5 -5
View File
@@ -2,7 +2,6 @@ export * as Catalog from "./catalog"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
import { ModelV2 } from "./model"
import { ModelRequest } from "./model-request"
import { ProviderV2 } from "./provider"
import { EventV2 } from "./event"
import { Policy } from "./policy"
@@ -73,7 +72,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) => {
@@ -86,10 +85,11 @@ export const layer = Layer.effect(
? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } }
: model.api
const request = {
...ModelRequest.merge({ ...provider.request, generation: {}, options: {} }, model.request),
headers: { ...provider.request.headers, ...model.request.headers },
body: { ...provider.request.body, ...model.request.body },
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),
}) {}
+4 -15
View File
@@ -4,7 +4,6 @@ import { define } from "../../plugin/internal"
import { Effect } from "effect"
import { Config } from "../../config"
import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request"
import { ProviderV2 } from "../../provider"
export const Plugin = define({
@@ -59,15 +58,11 @@ export const Plugin = define({
Object.assign(provider.request.body, item.request.body)
}
})
const providerApi = catalog.provider.get(providerID)?.provider.api
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
@@ -76,10 +71,8 @@ export const Plugin = define({
}
}
if (config.request !== undefined) {
ModelRequest.assign(model.request, {
headers: config.request.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
})
Object.assign(model.request.headers, config.request.headers)
Object.assign(model.request.body, config.request.body)
if (config.request.variant !== undefined) model.request.variant = config.request.variant
}
if (config.variants !== undefined) {
@@ -90,15 +83,11 @@ export const Plugin = define({
id: variant.id,
headers: {},
body: {},
generation: {},
options: {},
}
model.variants.push(existing)
}
ModelRequest.assign(existing, {
headers: variant.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
})
Object.assign(existing.headers, variant.headers)
Object.assign(existing.body, variant.body)
}
}
if (config.cost !== undefined) {
+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>

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