Merge branch 'mobile-session-layout' into mobile-bottom-nav

This commit is contained in:
Brendan Allan 2026-06-23 14:39:45 +08:00
commit 2f9d566fea
No known key found for this signature in database
GPG key ID: 41E835AEA046A32E
451 changed files with 22947 additions and 10507 deletions

View file

@ -65,7 +65,7 @@ jobs:
- name: Run unit tests - name: Run unit tests
timeout-minutes: 20 timeout-minutes: 20
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task run: GITHUB_ACTIONS=false bun turbo test
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
@ -99,7 +99,8 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: "24" # Playwright 1.59 hangs while extracting Chromium with Node 24.16.
node-version: "24.15"
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun

View file

@ -137,7 +137,7 @@ const table = sqliteTable("session", {
## Testing ## Testing
- Avoid mocks as much as possible - Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
- Test actual implementation, do not duplicate logic into tests - Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
@ -152,7 +152,7 @@ const table = sqliteTable("session", {
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op. - Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. - Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop. - Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work. - Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles. - Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership. - Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned. - Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.

View file

@ -24,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta
_Avoid_: System update, system notification, raw text diff _Avoid_: System update, system notification, raw text diff
**Context Epoch**: **Context Epoch**:
The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition. The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline.
**Baseline System Context**: **Baseline System Context**:
The full **System Context** rendered at the start of a **Context Epoch**. The full **System Context** rendered at the start of a **Context Epoch**.
@ -39,6 +39,18 @@ An expected temporary inability to observe a **Context Source** value; the runti
**Safe Provider-Turn Boundary**: **Safe Provider-Turn Boundary**:
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically. The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
**Admitted Prompt**:
A durable user input accepted into the Session inbox but not yet included in **Session History**.
**Prompt Promotion**:
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
**Provider Turn**:
One request to a model provider and the response projected from that request.
**Session Drain**:
One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
**Model Tool Output**: **Model Tool Output**:
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit. The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
@ -67,6 +79,11 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**. - Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes. - Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**. - At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline. - The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion. - Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history. - Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
@ -75,31 +92,28 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed. - Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**. - `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked. - `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
- `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context. - `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run.
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text. - **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**. - Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**. - Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location. - Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote. - Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move.
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values. - Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**. - The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam. - Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool. - Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
- Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent. - The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn.
- A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged baseline. - Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline.
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy. - Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily. - Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry. - Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them. - **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later. - The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- A **Context Epoch** begins with one immutable **Baseline System Context**. - A **Context Epoch** begins with one immutable **Baseline System Context**.
- A **Context Epoch** durably records the effective agent that owns its **Baseline System Context**.
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**. - A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. - A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache. - 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 always starts a new **Context Epoch** while preserving chronological conversation 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. - **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. - **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`. - 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`.

View file

@ -29,7 +29,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@ -86,7 +86,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@opencode-ai/cli", "name": "@opencode-ai/cli",
"version": "1.17.8", "version": "1.17.9",
"bin": { "bin": {
"lildax": "./bin/lildax.cjs", "lildax": "./bin/lildax.cjs",
}, },
@ -111,7 +111,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@ -147,7 +147,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@ -174,7 +174,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.82", "@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@ -196,7 +196,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@ -220,7 +220,7 @@
}, },
"packages/console/support": { "packages/console/support": {
"name": "@opencode-ai/console-support", "name": "@opencode-ai/console-support",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-core": "workspace:*",
@ -240,7 +240,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.17.8", "version": "1.17.9",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@ -276,6 +276,7 @@
"@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0", "@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0", "@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/context-async-hooks": "2.6.1",
@ -331,7 +332,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"effect": "catalog:", "effect": "catalog:",
@ -385,7 +386,7 @@
}, },
"packages/effect-drizzle-sqlite": { "packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite", "name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@ -399,7 +400,7 @@
}, },
"packages/effect-sqlite-node": { "packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node", "name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"effect": "catalog:", "effect": "catalog:",
}, },
@ -411,7 +412,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@hono/standard-validator": "catalog:", "@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@ -442,7 +443,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@ -458,10 +459,10 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@effect/platform-node": "4.0.0-beta.74", "@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.74", "@effect/platform-node-shared": "4.0.0-beta.83",
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@ -472,12 +473,12 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
"peerDependencies": { "peerDependencies": {
"effect": "4.0.0-beta.74", "effect": "4.0.0-beta.83",
}, },
}, },
"packages/llm": { "packages/llm": {
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@smithy/eventstream-codec": "4.2.14", "@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2", "@smithy/util-utf8": "4.2.2",
@ -495,7 +496,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.17.8", "version": "1.17.9",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@ -623,8 +624,9 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"effect": "catalog:", "effect": "catalog:",
"zod": "catalog:", "zod": "catalog:",
@ -661,7 +663,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@ -676,7 +678,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@opencode-ai/server", "name": "@opencode-ai/server",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
@ -690,7 +692,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@ -703,7 +705,7 @@
}, },
"packages/stats/app": { "packages/stats/app": {
"name": "@opencode-ai/stats-app", "name": "@opencode-ai/stats-app",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*", "@opencode-ai/stats-core": "workspace:*",
@ -736,7 +738,7 @@
}, },
"packages/stats/core": { "packages/stats/core": {
"name": "@opencode-ai/stats-core", "name": "@opencode-ai/stats-core",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@aws-sdk/client-athena": "3.933.0", "@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0", "@planetscale/database": "1.19.0",
@ -755,7 +757,7 @@
}, },
"packages/stats/server": { "packages/stats/server": {
"name": "@opencode-ai/stats-server", "name": "@opencode-ai/stats-server",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@aws-sdk/client-firehose": "3.933.0", "@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
@ -795,7 +797,7 @@
}, },
"packages/tui": { "packages/tui": {
"name": "@opencode-ai/tui", "name": "@opencode-ai/tui",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
@ -822,7 +824,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@ -871,7 +873,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@ -935,9 +937,9 @@
}, },
"catalog": { "catalog": {
"@cloudflare/workers-types": "4.20251008.0", "@cloudflare/workers-types": "4.20251008.0",
"@effect/opentelemetry": "4.0.0-beta.74", "@effect/opentelemetry": "4.0.0-beta.83",
"@effect/platform-node": "4.0.0-beta.74", "@effect/platform-node": "4.0.0-beta.83",
"@effect/sql-sqlite-bun": "4.0.0-beta.74", "@effect/sql-sqlite-bun": "4.0.0-beta.83",
"@hono/standard-validator": "0.2.0", "@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
@ -973,7 +975,7 @@
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2", "drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2", "drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.74", "effect": "4.0.0-beta.83",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
@ -1328,13 +1330,13 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.74", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.74" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-flpyqLPyr+THSe6ZCGRZl6hi+FqxbIXNSkslKGiRJAjbPabam9mSp7R3aC8biIMt6xE4Fd0LNfo4p2GplUkm2Q=="], "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.83", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.83" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-cPfCfp/ghu0itbX6Dqjdr4N0rbjng5ON4sUpnLHV5JJySG8zZpWmuOZLWIrfrNKT2ctYR1BYmp1aYCgkItaJLw=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.74", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.74", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74", "ioredis": "^5.7.0" } }, "sha512-/W16mKqxvhWINLjufzc0log1sl57exXQfwd+em398/zKCbmU3S7snXTDMN6w0ju2TtgK35qrsoGBXEochij6Sg=="], "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.83", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.83", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83", "ioredis": "^5.7.0" } }, "sha512-RmpVGu/+X/Bif3/g1Rzj8oFzTOknoVB3yHCa0b179vytPpKe+Kj9ZwKNcAnKWqHUDkbSPBq1Ca60mvOHr2/+LQ=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.74", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-C6C2hXixNcZXLaFF2u7B/FtOsqpdY7luaPuiGFBJza0P7EnYDkwaT3kB6lv7l/qctmkADc24qOsSCWIKRbC4jg=="], "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.83", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-+yr/+PJmKTgmJq1QOINSBPgLu7Cjc4CZcotBXnGjyDEizOmimFgTkN2B8PBJAKIKUWYWfobjXqC+58/VhhPKAw=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-RVMRVY7NhSoAp9cAAyy4TT6dt6NNZjOpWeqticoho9HNBukxQSUcu/kjcz4Iq9eoQfXadmepu8kZqtdZULM/fg=="], "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.83", "", { "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-6OaxLsWffxkh9pXYUSyj/AxjVb9URY2rG9U6atjxClWy30Jx77R9Pm3Rrc7cQ63kQurePavEw1bQbzQ/SILiQQ=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@ -3366,7 +3368,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@ -5898,6 +5900,10 @@
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], "@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
"@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
"@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
"@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], "@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@ -6700,6 +6706,10 @@
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
"@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],

View file

@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-LOxTad/iCquvJyonFOcz6/rDTPNDmwyBnykhWZJ5GC4=", "x86_64-linux": "sha256-oWSGu+SP66Aquy/0Vaq7Bgp8404ZdOWbQX+O7h3jxHU=",
"aarch64-linux": "sha256-iO+0vYhp+2x6ACmh5lQJ/2Ac4uZTqRZE/KhG3u0o6D8=", "aarch64-linux": "sha256-UsS0+c+GwtIukmWwQeFbY/3Oaz3t4Q7C6cFMGkmlyAY=",
"aarch64-darwin": "sha256-tpBydRbrJ+4QxmkGUt/BhME8q6ysCW/CXrsNshYgqDU=", "aarch64-darwin": "sha256-CArz92ewPmXO+ORFCBkCH8LzMpU/DjyaO4ic7QL0UpI=",
"x86_64-darwin": "sha256-QQcI6SK7WJ7dSkX6xZuSQPoUdwfoCaimVgoHCnrO0wY=" "x86_64-darwin": "sha256-rhnz9gmG6L06wIzfMhTaXDDEf6IbMD32CavqwXoqcUs="
} }
} }

View file

@ -30,9 +30,9 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@effect/opentelemetry": "4.0.0-beta.74", "@effect/opentelemetry": "4.0.0-beta.83",
"@effect/platform-node": "4.0.0-beta.74", "@effect/platform-node": "4.0.0-beta.83",
"@effect/sql-sqlite-bun": "4.0.0-beta.74", "@effect/sql-sqlite-bun": "4.0.0-beta.83",
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13", "@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
@ -61,7 +61,7 @@
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2", "drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2", "drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.74", "effect": "4.0.0-beta.83",
"ai": "6.0.168", "ai": "6.0.168",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"hono": "4.10.7", "hono": "4.10.7",

View file

@ -1,3 +1,8 @@
## Priorities
- Prioritise, in this order: stability, simplicity, performance.
- Before changing session or timeline code, record a production benchmark baseline and compare it after the change.
## Debugging ## Debugging
- NEVER try to restart the app, or the server process, EVER. - NEVER try to restart the app, or the server process, EVER.

View file

@ -0,0 +1,13 @@
- Prioritize stability, then simplicity, then measurement overhead.
- Use Playwright for scenario control, isolation, and completion checks.
- Use Chrome Performance traces for generic browser profiling.
- Use Electron `contentTracing` for packaged multi-process profiling.
- Keep custom probes only for product-specific measurements.
- Do not duplicate measurements across the harness, probes, and traces.
- Run benchmarks serially to avoid cross-test contention.
- Run benchmarks against production builds.
- Keep detailed profiling opt-in when it changes workload behavior.
- Preserve raw diagnostic data or use lossless representations.
- Do not enforce machine-dependent performance thresholds.
- Assert scenario completion and metric collection only.
- Keep normal test discovery free of manual benchmarks.

View file

@ -0,0 +1,77 @@
# Manual app performance suite
The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially.
Run the suite explicitly from `packages/app`:
```sh
bun run test:bench
```
PowerShell:
```powershell
$env:PLAYWRIGHT_WORKERS = "1"
bun run test:bench
```
The suite contains:
- cold and hot session-tab timing
- cached session repaint and mutation tracing
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle.
New benchmarks should look like normal Playwright tests:
```ts
import { benchmark, expect } from "../benchmark"
benchmark("measures one interaction", async ({ page, report }) => {
// Only scenario-specific setup and interaction belong here.
report({ durationMs: 42 })
})
```
The fixture requires every benchmark to call `report()`, automatically names and closes traces, captures navigation history, attaches that history when a test fails, and emits metrics as a consistent `BENCHMARK` JSON line.
```text
BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}}
```
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows.
This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer).
These Playwright benchmarks profile the shared app renderer in Chromium. A future packaged Electron benchmark that needs main-process and multi-process attribution should use Electron's official [`contentTracing`](https://www.electronjs.org/docs/latest/api/content-tracing/) API rather than extending this renderer harness with bespoke process instrumentation.
CPU and high-volume visual profiling are disabled by default. Set `TIMELINE_CPU_PROFILE=1` to enable both, or additionally set `TIMELINE_VISUAL_PROFILE=0` for CPU-only profiling.
The streaming scenario's 30x CPU throttle is a deterministic stress profile, not a simulated end-user device.
Benchmarks do not assert machine-dependent performance budgets. Streaming processes 160 deltas by default and reports renderer-observed completion time, throughput, RAF callback-gap distributions, frame-budget equivalents, and long tasks through final geometry settlement. Delta count and delivery batch are included in result context when overridden. These are main-thread callback diagnostics, not compositor presentation or dropped-frame measurements. Visual-only and geometry metrics are `null` when their probes are disabled. Tab metrics describe sampled DOM observations. Assertions verify scenario and metric collection completion. Repeated repaint states are run-length grouped, but every original observation timestamp is retained alongside raw mutation batches and layout shifts.
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
## Chrome traces
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
```sh
OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-performance-traces \
bunx playwright test --config e2e/performance/playwright.config.ts \
timeline/session-tab-switch-benchmark.spec.ts
```
The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies:
Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss.
```sh
bunx devtools-tracing stats <trace-path-from-BENCHMARK_PAGE>
```
INP analysis requires a trace with a supported navigation/interaction insight. Selector statistics require a trace captured with `OPENCODE_PERFORMANCE_SELECTOR_TRACE=1`.
`e2e/performance/playwright.uncapped.config.ts` disables Chromium frame-rate limiting for explicit uncapped diagnostics. Native product benchmarks should use the default Playwright configuration.

View file

@ -0,0 +1,144 @@
import { expect, test as base, type Browser, type Page, type TestInfo } from "@playwright/test"
import { startChromeTrace } from "./chrome-trace"
type BenchmarkFixtures = {
report: (metrics: Record<string, unknown>, context?: Record<string, unknown>) => void
reportState: { payload?: { metrics: Record<string, unknown>; context: Record<string, unknown> } }
benchmarkResult: void
}
export type PerformancePageDiagnostics = {
navigations: string[]
stop: () => Promise<string | undefined>
}
const pages = new WeakMap<Page, PerformancePageDiagnostics>()
export const benchmark = base.extend<BenchmarkFixtures>({
reportState: async ({}, use) => use({}),
report: async ({ reportState }, use) => {
await use((metrics, context = {}) => {
if (reportState.payload) throw new Error("Benchmark reported metrics more than once")
reportState.payload = { metrics, context }
})
},
benchmarkResult: [
async ({ reportState }, use, testInfo) => {
await use()
const missing = !reportState.payload
console.log(
`BENCHMARK ${JSON.stringify({
schemaVersion: 2,
runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
name: benchmarkName(testInfo),
status: missing ? "failed" : testInfo.status,
expectedStatus: testInfo.expectedStatus,
retry: testInfo.retry,
repeatEachIndex: testInfo.repeatEachIndex,
context: {
project: testInfo.project.name,
platform: process.platform,
...reportState.payload?.context,
},
metrics: reportState.payload?.metrics ?? null,
error: missing ? "Benchmark did not report metrics" : undefined,
})}`,
)
if (missing && testInfo.status === testInfo.expectedStatus)
throw new Error(`Benchmark did not report metrics: ${benchmarkName(testInfo)}`)
},
{ auto: true },
],
page: async ({ page }, use, testInfo) => {
const name = benchmarkName(testInfo)
const diagnostics = await observePerformancePage(page, name)
try {
await use(page)
} finally {
try {
await reportPerformancePage(name, diagnostics, testInfo)
} finally {
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach("performance-navigations", {
body: JSON.stringify(diagnostics.navigations, null, 2),
contentType: "application/json",
})
}
}
}
},
})
function benchmarkName(testInfo: TestInfo) {
return testInfo.titlePath.slice(1).join(" > ")
}
export { expect }
async function observePerformancePage(page: Page, name: string) {
const navigations: string[] = []
const onNavigation = (frame: ReturnType<Page["mainFrame"]>) => {
if (frame === page.mainFrame()) navigations.push(frame.url())
}
page.on("framenavigated", onNavigation)
const stopTrace = await startChromeTrace(page, name).catch((error) => {
page.off("framenavigated", onNavigation)
throw error
})
let stopping: Promise<string | undefined> | undefined
const diagnostics: PerformancePageDiagnostics = {
navigations,
stop() {
page.off("framenavigated", onNavigation)
return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined))
},
}
pages.set(page, diagnostics)
return diagnostics
}
export async function withBenchmarkPage<T>(
browser: Browser,
name: string,
run: (page: Page) => Promise<T>,
testInfo?: TestInfo,
) {
const context = await browser.newContext()
try {
const page = await context.newPage()
const diagnostics = await observePerformancePage(page, name)
try {
return await run(page)
} finally {
await reportPerformancePage(name, diagnostics, testInfo)
}
} finally {
await context.close()
}
}
async function reportPerformancePage(name: string, diagnostics: PerformancePageDiagnostics, testInfo?: TestInfo) {
const trace = await diagnostics.stop()
console.log(
`BENCHMARK_PAGE ${JSON.stringify({
schemaVersion: 2,
runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
name,
test: testInfo ? benchmarkName(testInfo) : undefined,
retry: testInfo?.retry,
repeatEachIndex: testInfo?.repeatEachIndex,
context: {
platform: process.platform,
trace,
selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1",
},
navigations: diagnostics.navigations,
})}`,
)
}
export function benchmarkDiagnostics(page: Page) {
const diagnostics = pages.get(page)
if (!diagnostics) throw new Error("Performance diagnostics are not installed for this page")
return diagnostics
}

View file

@ -0,0 +1,95 @@
import type { CDPSession, Page } from "@playwright/test"
import path from "node:path"
import { mkdir, open, rename } from "node:fs/promises"
import { Buffer } from "node:buffer"
import { createHash, randomUUID } from "node:crypto"
const categories = [
"-*",
"devtools.timeline",
"v8.execute",
"disabled-by-default-devtools.timeline",
"disabled-by-default-devtools.timeline.frame",
"toplevel",
"blink.console",
"blink.user_timing",
"latencyInfo",
"disabled-by-default-devtools.timeline.stack",
"disabled-by-default-v8.cpu_profiler",
]
export async function startChromeTrace(page: Page, name: string) {
const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR
if (!directory) return
const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1"
const file = await prepareChromeTrace(directory, name, selectors)
const session = await page.context().newCDPSession(page)
try {
await session.send("Tracing.start", {
transferMode: "ReturnAsStream",
traceConfig: {
excludedCategories: categories
.filter((category) => category.startsWith("-"))
.map((category) => category.slice(1)),
includedCategories: [
...categories.filter((category) => !category.startsWith("-")),
...(selectors
? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"]
: []),
],
},
})
} catch (error) {
await Promise.allSettled([session.detach()])
throw error
}
let stopping: Promise<string> | undefined
return () =>
(stopping ??= (async () => {
try {
const complete = new Promise<{ stream?: string; dataLossOccurred: boolean }>((resolve) =>
session.once("Tracing.tracingComplete", resolve),
)
await session.send("Tracing.end")
const result = await complete
if (!result.stream) throw new Error(`Chrome trace stream missing: ${file}`)
const partial = `${file}.partial`
await writeProtocolStream(session, result.stream, partial)
if (result.dataLossOccurred) throw new Error(`Chrome trace lost data; partial capture retained: ${partial}`)
await rename(partial, file)
return file
} finally {
await Promise.allSettled([session.detach()])
}
})())
}
export async function prepareChromeTrace(
directory: string,
name: string,
selectors: boolean,
nonce = randomUUID().slice(0, 8),
) {
await mkdir(directory, { recursive: true })
const run = process.env.OPENCODE_PERFORMANCE_RUN_ID ?? "manual"
const hash = createHash("sha256").update(name).digest("hex").slice(0, 8)
return path.join(
directory,
`${run}-${name.replace(/[^a-zA-Z0-9_-]/g, "-")}-${hash}-${nonce}${selectors ? "-selectors" : ""}.json`,
)
}
async function writeProtocolStream(session: CDPSession, handle: string, file: string) {
const output = await open(file, "wx")
try {
while (true) {
const chunk = await session.send("IO.read", { handle })
await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data)
if (chunk.eof) break
}
} finally {
await Promise.allSettled([output.close(), session.send("IO.close", { handle })])
}
}

View file

@ -0,0 +1,20 @@
import config from "../../playwright.config"
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
process.env.PLAYWRIGHT_SERVER_PORT = String(port)
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
export default {
...config,
testDir: ".",
testIgnore: "unit/**",
outputDir: "../test-results/performance",
fullyParallel: false,
workers: 1,
reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]],
webServer: {
...config.webServer,
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
reuseExistingServer: false,
},
}

View file

@ -0,0 +1,13 @@
import config from "./playwright.config"
export default {
...config,
outputDir: "../test-results/performance-uncapped",
reporter: [["html", { outputFolder: "../playwright-report/performance-uncapped", open: "never" }], ["line"]],
use: {
...config.use,
launchOptions: {
args: ["--disable-frame-rate-limit", "--disable-gpu-vsync"],
},
},
}

View file

@ -0,0 +1,49 @@
import { benchmark, expect } from "../benchmark"
import { expectSessionTitle } from "../../utils/waits"
import { fixture } from "./session-timeline-stress.fixture"
import {
collectCachedRepaintTrace,
compressCachedRepaintTrace,
installCachedRepaintProbe,
waitForCachedRepaintWindow,
} from "./session-tab-repaint-probe"
import { waitForStableTimeline } from "./session-tab-switch-probe"
import {
installStressSessionTabs,
installTimelineSettings,
mockStressTimeline,
stressSessionHref,
} from "./timeline-test-helpers"
benchmark("samples cached session repaint after the click", async ({ page, report }) => {
benchmark.setTimeout(120_000)
await mockStressTimeline(page)
await installStressSessionTabs(page)
await installTimelineSettings(page)
await page.goto(stressSessionHref(fixture.targetID))
await expectSessionTitle(page, fixture.expected.targetTitle)
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await page
.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`)
.first()
.click()
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
await installCachedRepaintProbe(page, {
targetHref: stressSessionHref(fixture.targetID),
destination: fixture.messages[fixture.targetID].map((message) => message.info.id),
source: fixture.messages[fixture.sourceID].map((message) => message.info.id),
last: fixture.expected.targetMessageIDs.at(-1)!,
windowMs: 1_000,
})
await page
.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`)
.first()
.click()
await Promise.all([expectSessionTitle(page, fixture.expected.targetTitle), waitForCachedRepaintWindow(page, 1_000)])
const result = await collectCachedRepaintTrace(page)
report(compressCachedRepaintTrace(result))
expect(result.samples.length).toBeGreaterThan(0)
})

View file

@ -0,0 +1,251 @@
import type { Page } from "@playwright/test"
type CachedRepaintTrace = {
timeOriginEpochMs: number
startedAtPerformanceMs: number
samples: {
observedAtMs: number
root: number | undefined
scrollTop: number
scrollHeight: number
bottomErrorPx: number | undefined
last: boolean
rows: { key: string | undefined; node: number; top: number; bottom: number }[]
mounted: number
center: string | undefined
destination: string[]
source: string[]
}[]
mutations: { observedAtMs: number; changed: { type: string; node: number }[] }[]
shifts: { occurredAtMs: number; value: number }[]
windowMs: number
running: boolean
stop: () => void
}
export async function installCachedRepaintProbe(
page: Page,
input: { targetHref: string; destination: string[]; source: string[]; last: string; windowMs: number },
) {
await page.evaluate(({ targetHref, destination, source, last, windowMs }) => {
const destinationIDs = new Set(destination)
const sourceIDs = new Set(source)
const nodeIDs = new WeakMap<Node, number>()
let nextNodeID = 1
const id = (node: Node) => {
const current = nodeIDs.get(node)
if (current) return current
nodeIDs.set(node, nextNodeID)
return nextNodeID++
}
const state: CachedRepaintTrace = {
timeOriginEpochMs: performance.timeOrigin,
startedAtPerformanceMs: 0,
samples: [],
mutations: [],
shifts: [],
windowMs,
running: false,
stop: () => {},
}
const recordShifts = (entries: PerformanceEntry[]) => {
if (!state.running) return
state.shifts.push(
...entries
.map((entry) => {
if (
entry.startTime < state.startedAtPerformanceMs ||
entry.startTime > state.startedAtPerformanceMs + state.windowMs
)
return
return {
occurredAtMs: entry.startTime - state.startedAtPerformanceMs,
value: (entry as PerformanceEntry & { value: number }).value,
}
})
.filter((entry): entry is { occurredAtMs: number; value: number } => entry !== undefined),
)
}
const shiftObserver = new PerformanceObserver((entries) => recordShifts(entries.getEntries()))
shiftObserver.observe({ type: "layout-shift" })
const recordMutations = (entries: MutationRecord[]) => {
if (!state.running) return
const observedAtMs = performance.now() - state.startedAtPerformanceMs
if (observedAtMs > state.windowMs) return
const changed = entries.flatMap((entry) => [
...[...entry.addedNodes].map((node) => ({ type: "add", node: id(node) })),
...[...entry.removedNodes].map((node) => ({ type: "remove", node: id(node) })),
])
if (changed.length) state.mutations.push({ observedAtMs, changed })
}
const mutationObserver = new MutationObserver(recordMutations)
mutationObserver.observe(document.documentElement, { childList: true, subtree: true })
state.stop = () => {
recordShifts(shiftObserver.takeRecords())
recordMutations(mutationObserver.takeRecords())
state.running = false
shiftObserver.disconnect()
mutationObserver.disconnect()
}
const sample = () => {
if (!state.running) return
setTimeout(() => {
if (!state.running) return
const observedAtMs = performance.now() - state.startedAtPerformanceMs
if (observedAtMs > state.windowMs) return
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (root) {
const view = root.getBoundingClientRect()
const rows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
.map((element) => ({
key: element.dataset.timelineKey,
node: id(element),
rect: element.getBoundingClientRect(),
}))
.filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
.map((item) => ({
key: item.key,
node: item.node,
top: item.rect.top - view.top,
bottom: item.rect.bottom - view.top,
}))
const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((element) => element.dataset.messageId!)
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
state.samples.push({
observedAtMs,
root: id(root),
scrollTop: root.scrollTop,
scrollHeight: root.scrollHeight,
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
last: messages.includes(last),
rows,
mounted: root.querySelectorAll("[data-timeline-key]").length,
center: document
.elementFromPoint(view.left + view.width / 2, view.top + view.height / 2)
?.textContent?.slice(0, 80),
destination: messages.filter((messageID) => destinationIDs.has(messageID)),
source: messages.filter((messageID) => sourceIDs.has(messageID)),
})
} else {
state.samples.push({
observedAtMs,
root: undefined,
scrollTop: 0,
scrollHeight: 0,
bottomErrorPx: undefined,
last: false,
rows: [],
mounted: 0,
center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
destination: [],
source: [],
})
}
requestAnimationFrame(sample)
}, 0)
}
document.addEventListener(
"click",
(event) => {
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== targetHref) return
state.startedAtPerformanceMs = performance.now()
state.running = true
requestAnimationFrame(sample)
},
{ capture: true, once: true },
)
;(window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash = state
}, input)
}
export function layoutShiftSample(entry: Pick<PerformanceEntry, "startTime"> & { value: number }, started: number) {
if (entry.startTime < started) return
return { occurredAtMs: entry.startTime - started, value: entry.value }
}
export async function waitForCachedRepaintWindow(page: Page, durationMs: number) {
await page.waitForFunction((durationMs) => {
const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash
return !!state?.running && performance.now() - state.startedAtPerformanceMs >= durationMs
}, durationMs)
}
export async function collectCachedRepaintTrace(page: Page) {
return page.evaluate(() => {
const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash!
state.stop()
return state
})
}
export function summarizeCachedRepaintTrace(trace: CachedRepaintTrace) {
const roots = trace.samples.map((sample) => sample.root)
const bottomErrors = trace.samples.flatMap((sample) =>
sample.bottomErrorPx === undefined ? [] : [Math.abs(sample.bottomErrorPx)],
)
const category = (sample: CachedRepaintTrace["samples"][number]) => {
if (sample.source.length) return "source"
if (sample.root === undefined || sample.rows.length === 0) return "blank"
if (!sample.destination.length) return "unknown"
if (sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) return "correct"
return "wrongDestination"
}
return {
samples: trace.samples.length,
durationMs: trace.samples.at(-1)?.observedAtMs ?? 0,
firstSampleObservedMs: trace.samples[0]?.observedAtMs,
firstSampleCorrect: trace.samples[0] ? category(trace.samples[0]) === "correct" : false,
blankSamples: trace.samples.filter((sample) => category(sample) === "blank").length,
sourceSamples: trace.samples.filter((sample) => category(sample) === "source").length,
wrongDestinationSamples: trace.samples.filter((sample) => category(sample) === "wrongDestination").length,
unknownSamples: trace.samples.filter((sample) => category(sample) === "unknown").length,
rootChanges: roots.slice(1).filter((root, index) => root !== roots[index]).length,
mountedMin: trace.samples.length ? Math.min(...trace.samples.map((sample) => sample.mounted)) : 0,
mountedMax: Math.max(...trace.samples.map((sample) => sample.mounted)),
maxBottomErrorPx: Math.max(0, ...bottomErrors),
mutationBatches: trace.mutations.length,
addedNodes: trace.mutations.reduce(
(sum, batch) => sum + batch.changed.filter((change) => change.type === "add").length,
0,
),
removedNodes: trace.mutations.reduce(
(sum, batch) => sum + batch.changed.filter((change) => change.type === "remove").length,
0,
),
layoutShiftValueSum: trace.shifts.reduce((sum, shift) => sum + shift.value, 0),
maxLayoutShiftValue: Math.max(0, ...trace.shifts.map((shift) => shift.value)),
}
}
export function compressCachedRepaintTrace(trace: CachedRepaintTrace) {
const samples: {
observedAtMs: number[]
state: Omit<CachedRepaintTrace["samples"][number], "observedAtMs">
}[] = []
for (const sample of trace.samples) {
const { observedAtMs, ...state } = sample
const previous = samples.at(-1)
if (previous && JSON.stringify(previous.state) === JSON.stringify(state)) {
previous.observedAtMs.push(observedAtMs)
continue
}
samples.push({ observedAtMs: [observedAtMs], state })
}
return {
timeOriginEpochMs: trace.timeOriginEpochMs,
startedAtPerformanceMs: trace.startedAtPerformanceMs,
windowMs: trace.windowMs,
summary: summarizeCachedRepaintTrace(trace),
samples,
mutations: trace.mutations,
shifts: trace.shifts,
}
}

View file

@ -0,0 +1,79 @@
import type { Page } from "@playwright/test"
import { expectSessionTitle } from "../../utils/waits"
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
import { fixture } from "./session-timeline-stress.fixture"
import { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
type Result = Awaited<ReturnType<typeof measureSessionSwitch>>
benchmark("benchmarks cold and hot session tab switching", async ({ browser, report }, testInfo) => {
benchmark.setTimeout(180_000)
const results = { cold: [] as Result[], hot: [] as Result[] }
for (const mode of ["cold", "hot"] as const) {
for (let run = 0; run < 5; run++) {
results[mode].push(
await withBenchmarkPage(browser, `session-tab-switch-${mode}-${run}`, (page) => trial(page, mode), testInfo),
)
}
}
report({ results, summary: summarize(results) })
})
async function trial(page: Page, mode: "cold" | "hot") {
await mockStressTimeline(page)
await installStressSessionTabs(page)
if (mode === "hot") {
await page.goto(stressSessionHref(fixture.targetID))
await expectSessionTitle(page, fixture.expected.targetTitle)
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
} else {
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
}
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id)
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id)
const lastID = fixture.expected.targetMessageIDs.at(-1)!
const href = stressSessionHref(fixture.targetID)
const result = await measureSessionSwitch(page, {
destinationIDs,
sourceIDs,
lastID,
href,
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
})
return result
}
function summarize(results: Record<"cold" | "hot", Result[]>) {
const stats = (values: (number | null)[]) => {
const sorted = values.filter((value): value is number => value !== null).sort((a, b) => a - b)
return {
min: sorted[0] ?? null,
median: sorted[Math.floor(sorted.length / 2)] ?? null,
max: sorted.at(-1) ?? null,
missing: values.length - sorted.length,
}
}
return Object.fromEntries(
Object.entries(results).map(([mode, values]) => [
mode,
{
firstDestinationObservedMs: stats(values.map((value) => value.firstDestinationObservedMs)),
firstCorrectObservedMs: stats(values.map((value) => value.firstCorrectObservedMs)),
stableObservedMs: stats(values.map((value) => value.stableObservedMs)),
},
]),
)
}
async function switchSession(page: Page, sessionID: string, title: string) {
const href = stressSessionHref(sessionID)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
await expectSessionTitle(page, title)
}

View file

@ -0,0 +1,46 @@
export type SessionSwitchSample = {
observedAtMs: number
destination: string[]
source: string[]
hasVisibleRows: boolean
last: boolean
bottomErrorPx?: number
}
export function classifySessionSwitch(samples: SessionSwitchSample[]) {
const firstDestination = samples.findIndex((sample) => sample.destination.length > 0)
const firstCorrect = samples.findIndex(isCorrectDestination)
const stable = samples.findIndex((_, index) => isStableSessionSwitch(samples.slice(index, index + 3)))
return {
firstDestinationObservedMs: samples[firstDestination]?.observedAtMs ?? null,
firstCorrectObservedMs: samples[firstCorrect]?.observedAtMs ?? null,
stableObservedMs: samples[stable + 2]?.observedAtMs ?? null,
wrongDestinationSamples: samples
.slice(firstDestination)
.filter((sample) => sample.destination.length > 0 && !sample.last).length,
blankSamples: samples.filter((sample) => !sample.hasVisibleRows).length,
unknownSamples: samples.filter(
(sample) => sample.hasVisibleRows && sample.destination.length === 0 && sample.source.length === 0,
).length,
sourceSamples: samples.filter((sample) => sample.source.length > 0).length,
}
}
export function isCorrectDestination(sample: SessionSwitchSample) {
return (
sample.destination.length > 0 &&
sample.source.length === 0 &&
sample.last &&
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1
)
}
export function isStableSessionSwitch(samples: SessionSwitchSample[]) {
return samples.length === 3 && samples.every(isCorrectDestination)
}
export function isStableDestination(samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[]) {
return (
samples.length === 3 && samples.every((sample) => sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
)
}

View file

@ -0,0 +1,152 @@
import { expect, type Page } from "@playwright/test"
import { classifySessionSwitch, isStableDestination, type SessionSwitchSample } from "./session-tab-switch-metrics"
type SessionSwitchProbe = {
samples: SessionSwitchSample[]
stop: () => void
}
async function installSessionSwitchProbe(
page: Page,
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
) {
await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => {
const destination = new Set(destinationIDs)
const source = new Set(sourceIDs)
const samples: SessionSwitchSample[] = []
let started: number | undefined
let running = true
const sample = () => {
if (!running || started === undefined) return
setTimeout(() => {
if (!running || started === undefined) return
const observedAtMs = performance.now() - started
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (root) {
const view = root.getBoundingClientRect()
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((element) => element.dataset.messageId!)
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
samples.push({
observedAtMs,
destination: visible.filter((id) => destination.has(id)),
source: visible.filter((id) => source.has(id)),
hasVisibleRows,
last: visible.includes(lastID),
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
})
} else {
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false })
}
requestAnimationFrame(sample)
}, 0)
}
document.addEventListener(
"click",
(event) => {
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
requestAnimationFrame(sample)
},
{ capture: true, once: true },
)
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
samples,
stop: () => {
running = false
},
}
}, input)
}
async function waitForStableSessionSwitch(page: Page) {
await page.waitForFunction(() => {
const samples = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.samples
if (!samples) return false
return samples.some((_, index) => {
const stable = samples.slice(index, index + 3)
return (
stable.length === 3 &&
stable.every(
(sample) =>
sample.destination.length > 0 &&
sample.source.length === 0 &&
sample.last &&
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
)
)
})
})
}
async function collectSessionSwitchResult(page: Page) {
const samples = await page.evaluate(() => {
const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe!
probe.stop()
return probe.samples
})
return classifySessionSwitch(samples)
}
export async function measureSessionSwitch(
page: Page,
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise<void> },
) {
const { switch: run, ...probe } = input
await installSessionSwitchProbe(page, probe)
await run()
await waitForStableSessionSwitch(page)
return collectSessionSwitchResult(page)
}
export async function waitForStableTimeline(page: Page, lastID: string) {
const samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[] = []
await expect
.poll(
async () => {
samples.push(
await page.evaluate(
(lastID) =>
new Promise<Pick<SessionSwitchSample, "last" | "bottomErrorPx">>((resolve) => {
requestAnimationFrame(() =>
setTimeout(() => {
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (!root) {
resolve({ last: false })
return
}
const view = root.getBoundingClientRect()
const last = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].some((element) => {
if (element.dataset.messageId !== lastID) return false
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
const spacer = root
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect()
resolve({ last, bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined })
}, 0),
)
}),
lastID,
),
)
return isStableDestination(samples.slice(-3))
},
{ timeout: 30_000, intervals: [0] },
)
.toBe(true)
}

View file

@ -0,0 +1,488 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
import { expect } from "../benchmark"
const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression"
const sessionID = "ses_timeline_state_regression"
const userMessageID = "msg_user_regression"
const assistantMessageID = "msg_assistant_regression"
const editPartID = "prt_0001_edit"
export const textPartID = "prt_9999_text"
const title = "Timeline collapse state regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type EventPayload = {
directory: string
payload: Record<string, unknown>
}
const userMessage = {
info: {
id: userMessageID,
sessionID,
role: "user",
time: { created: 1700000000000 },
summary: { diffs: [] },
agent: "build",
model,
},
parts: [
{
id: "prt_user_text",
sessionID,
messageID: userMessageID,
type: "text",
text: "Please edit the file.",
},
],
}
const editPart = {
id: editPartID,
sessionID,
messageID: assistantMessageID,
type: "tool",
callID: "call_edit_regression",
tool: "edit",
state: {
status: "completed",
input: { filePath: "src/regression.ts" },
output: "Edited src/regression.ts",
title: "src/regression.ts",
metadata: {
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: "export const value = 'before'\n",
after: "export const value = 'after'\n",
},
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
},
time: { start: 1700000001000, end: 1700000002000 },
},
}
const streamedTextPart = {
id: textPartID,
sessionID,
messageID: assistantMessageID,
type: "text",
text: "Streaming added a later assistant text part.",
}
const assistantMessage = {
info: {
id: assistantMessageID,
sessionID,
role: "assistant",
time: { created: 1700000001000 },
parentID: userMessageID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
},
parts: [editPart],
}
export async function setupTimelineBenchmark(page: Page, options: { historyTurns: number; eventBatch: number }) {
const events: EventPayload[] = []
let eventBatch = options.eventBatch
await mockOpenCodeServer(page, {
directory,
project: project(),
provider: provider(),
sessions: [session()],
pageMessages: () => ({
items: [
...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(),
userMessage,
assistantMessage,
],
}),
events: () => events.splice(0, eventBatch),
eventRetry: 16,
})
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
})
await page.setViewportSize({ width: 1366, height: 768 })
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expectAppVisible(scroller)
return {
scroller,
text,
transport: {
enqueue(payload: EventPayload | EventPayload[]) {
events.push(...(Array.isArray(payload) ? payload : [payload]))
},
pendingCount() {
return events.length
},
releaseAll() {
eventBatch = events.length
},
},
async scrollToBottom() {
await scroller.evaluate((element) => {
element.scrollTop = element.scrollHeight
})
},
async waitForStableGeometry() {
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await page.waitForFunction((partID) => {
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector(`[data-timeline-part-id="${partID}"]`),
)
if (!root) return false
return new Promise<boolean>((resolve) => {
const height = root.scrollHeight
requestAnimationFrame(() =>
requestAnimationFrame(() =>
resolve(root.scrollHeight === height && root.scrollHeight - root.clientHeight - root.scrollTop <= 1),
),
)
})
}, textPartID)
},
}
}
export function buildInitialStreamEvent(deltaCount: number): EventPayload {
return {
directory,
payload: {
type: "message.part.updated",
properties: {
part: {
...streamedTextPart,
text: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
},
},
},
}
}
export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
return Array.from({ length: deltaCount }, (_, index) => ({
directory,
payload: {
type: "message.part.delta",
properties: {
messageID: assistantMessageID,
partID: textPartID,
field: "text",
delta: streamChunk(index + 1, deltaCount + 1),
},
},
}))
}
function performanceTurn(index: number) {
const suffix = String(index).padStart(4, "0")
const userID = `msg_0000_${suffix}_a_user`
const assistantID = `msg_0000_${suffix}_b_assistant`
const before = historicalSource(index, false)
const after = historicalSource(index, true)
const parts = [
...(index % 5 === 0
? [
{
id: `prt_0000_${suffix}_reasoning`,
sessionID,
messageID: assistantID,
type: "reasoning",
text: `Reviewing the existing implementation. ${"constraint analysis ".repeat(20)}`,
time: { start: 1690000001000 + index * 2_000, end: 1690000001200 + index * 2_000 },
},
]
: []),
{
id: `prt_0000_${suffix}_assistant`,
sessionID,
messageID: assistantID,
type: "text",
text: historicalMarkdown(index),
},
...(index % 8 === 0
? [
{
id: `prt_0000_${suffix}_edit`,
sessionID,
messageID: assistantID,
type: "tool",
callID: `call_0000_${suffix}_edit`,
tool: "edit",
state: {
status: "completed",
input: { filePath: `src/history-${index}.ts` },
output: `Edited src/history-${index}.ts`,
title: `src/history-${index}.ts`,
metadata: {
filediff: { file: `src/history-${index}.ts`, additions: 48, deletions: 48, before, after },
},
time: { start: 1690000001200 + index * 2_000, end: 1690000001400 + index * 2_000 },
},
},
]
: []),
...(index % 12 === 0
? [
{
id: `prt_0000_${suffix}_write`,
sessionID,
messageID: assistantID,
type: "tool",
callID: `call_0000_${suffix}_write`,
tool: "write",
state: {
status: "completed",
input: { filePath: `src/generated-${index}.tsx`, content: after },
output: `Wrote src/generated-${index}.tsx`,
title: `src/generated-${index}.tsx`,
metadata: {
filediff: { file: `src/generated-${index}.tsx`, additions: 32, deletions: 0, before: "", after },
},
time: { start: 1690000001400 + index * 2_000, end: 1690000001500 + index * 2_000 },
},
},
]
: []),
...(index % 16 === 0
? [
{
id: `prt_0000_${suffix}_patch`,
sessionID,
messageID: assistantID,
type: "tool",
callID: `call_0000_${suffix}_patch`,
tool: "apply_patch",
state: {
status: "completed",
input: { patchText: realisticPatch(index) },
output: "Success. Updated src/components/SessionCard.tsx",
title: "src/components/SessionCard.tsx",
metadata: {
files: [
{
filePath: "src/components/SessionCard.tsx",
relativePath: "src/components/SessionCard.tsx",
type: "update",
additions: 8,
deletions: 3,
patch: realisticPatch(index),
before,
after,
},
],
},
time: { start: 1690000001500 + index * 2_000, end: 1690000001700 + index * 2_000 },
},
},
]
: []),
]
return [
{
info: {
id: userID,
sessionID,
role: "user",
time: { created: 1690000000000 + index * 2_000 },
summary: { diffs: [] },
agent: "build",
model,
},
parts: [
{
id: `prt_0000_${suffix}_user`,
sessionID,
messageID: userID,
type: "text",
text: `Historical prompt ${index}`,
},
],
},
{
info: {
id: assistantID,
sessionID,
role: "assistant",
time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 },
parentID: userID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
finish: "stop",
},
parts,
},
]
}
function historicalMarkdown(index: number) {
const code = `import { For, Show, createSignal } from "solid-js"
type SessionRow = { id: string; title: string; active: boolean }
export function SessionList(props: { rows: SessionRow[] }) {
const [selected, setSelected] = createSignal<string>()
return (
<section aria-label="Sessions">
<For each={props.rows}>{(row) => (
<button classList={{ active: row.active }} onClick={() => setSelected(row.id)}>
<Show when={selected() === row.id} fallback={row.title}>{row.title.toUpperCase()}</Show>
</button>
)}</For>
</section>
)
}`
return `## Session renderer review ${index}
The active session keeps **semantic row identity** while reconciling measured content. See [Solid documentation](https://docs.solidjs.com/) and the inline \`measureElement(node)\` call.
| Concern | Current behavior | Verification |
| --- | --- | --- |
| streaming | appends Markdown blocks | painted frames |
| geometry | anchors visible rows | DOM coordinates |
| tools | preserves expanded state | keyed remount probe |
> Long sessions combine Markdown, syntax highlighting, tool output, and asynchronously rendered diffs.
${index % 4 === 0 ? `\`\`\`tsx\n${code}\n\`\`\`\n\n\`\`\`bash\nbun typecheck\nbun test --preload ./happydom.ts ./src/pages/session\ngit diff --check\n\`\`\`` : "- preserve the viewport anchor\n- avoid replacing stable Markdown nodes\n- process provider deltas without blocking input"}`
}
function historicalSource(index: number, updated: boolean) {
const method = updated ? "toLocaleUpperCase(props.locale)" : "toUpperCase()"
const limit = updated ? 24 : 20
return `import { createMemo, For } from "solid-js"
type Message = {
id: string
role: "user" | "assistant"
text: string
tokens: { input: number; output: number }
}
export function MessageSummary(props: { messages: Message[]; locale: string }) {
const visible = createMemo(() => props.messages.filter((message) => message.text.trim()).slice(-${limit}))
const total = createMemo(() => visible().reduce((sum, message) => sum + message.tokens.output, 0))
return (
<article data-session-index="${index}">
<header>{total().toLocaleString(props.locale)} output tokens</header>
<For each={visible()}>{(message) => <p data-role={message.role}>{message.text.${method}}</p>}</For>
</article>
)
}
`
}
function realisticPatch(index: number) {
return `*** Begin Patch
*** Update File: src/components/SessionCard.tsx
@@
-const title = props.session.title.toUpperCase()
-const messages = props.messages.slice(-20)
+const title = props.session.title.toLocaleUpperCase(props.locale)
+const messages = props.messages.filter((message) => message.text.trim()).slice(-24)
+const outputTokens = messages.reduce((sum, message) => sum + message.tokens.output, 0)
@@
- <h2>{title}</h2>
+ <h2 data-session-index="${index}">{title}</h2>
+ <span>{outputTokens.toLocaleString(props.locale)} output tokens</span>
*** End Patch`
}
export function streamChunk(index: number, count: number) {
if (index === 0) return `\n\n## Implementation plan\n\nStreaming **bold analysis`
if (index === count - 1)
return `\n\`\`\`\n\n## Verification\n\n- **Typecheck:** passed\n- **Timeline geometry:** stable\n- **Streaming output:** benchmark-complete <!-- stream-${index} -->`
const section = Math.floor(index / 18) + 1
const fragments = [
` continues across three`,
` or four word`,
` provider deltas and`,
` closes in this fragment**. <!-- stream-${index} -->\n\n`,
`| Concern | State`,
` | Verification |\n|`,
` --- | ---`,
` | --- |\n|`,
` markdown | incremental |`,
` painted frames | <!-- stream-${index} -->\n\n`,
`\`\`\`tsx\nconst row: SessionRow`,
` = rows[index] ??`,
` fallback\nconst title =`,
` row.title.toLocaleUpperCase(locale)\n`,
`const selected = createMemo(()`,
` => row.id ===`,
` activeID()) // stream-${index}\n`,
`// stream-${index}\n\`\`\`\n\n### Iteration ${section}\n\nStreaming **bold analysis`,
]
return fragments[(index - 1) % fragments.length]!
}
function project() {
return {
id: projectID,
worktree: directory,
vcs: "git",
name: "timeline-state-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
}
}
function session() {
return {
id: sessionID,
slug: "timeline-state-regression",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
}
}
function provider() {
return {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}

View file

@ -0,0 +1,85 @@
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
import {
buildInitialStreamEvent,
buildStreamDeltaEvents,
setupTimelineBenchmark,
textPartID,
} from "./session-timeline-benchmark.fixture"
import { startTimelineProfile } from "./session-timeline-profile"
import {
collectTimelineStreamMetrics,
installTimelineStreamProbe,
startTimelineStreamProbe,
} from "./session-timeline-stream-probe"
benchmark.describe("performance: session timeline streaming", () => {
benchmark("streams assistant text without remounting or oscillating", async ({ page, report }) => {
benchmark.setTimeout(480_000)
const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30)
const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160)
const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320)
const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1)
const minimal = process.env.TIMELINE_MINIMAL === "1"
const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1"
const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0"
const fixture = await setupTimelineBenchmark(page, {
historyTurns,
eventBatch,
})
fixture.transport.enqueue(buildInitialStreamEvent(deltaCount))
const contentStart = performance.now()
await expect(fixture.text).toBeVisible()
await expect(fixture.text).toContainText("Implementation plan")
const initialContentObservedMs = performance.now() - contentStart
await fixture.scrollToBottom()
await fixture.waitForStableGeometry()
const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU })
await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal })
const deltas = buildStreamDeltaEvents(deltaCount)
await startTimelineStreamProbe(page)
fixture.transport.enqueue(deltas)
await page.waitForFunction(
(finalIndex) =>
(
window as Window & {
__timelineStreamBenchmark?: { applied: { index: number }[] }
}
).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex),
deltaCount,
{ timeout: 420_000 },
)
await expect(fixture.text).toContainText("benchmark-complete")
await expect(fixture.text).toContainText("Streaming")
await fixture.waitForStableGeometry()
const metrics = await collectTimelineStreamMetrics(page, {
textPartID,
finalIndex: deltaCount,
navigations: benchmarkDiagnostics(page).navigations,
})
const delivered = deltas.length - fixture.transport.pendingCount()
await profile.stop()
report(
{
endToEndInitialContentObservedMs: initialContentObservedMs,
...metrics,
deliveredDeltas: delivered,
pendingDeltas: fixture.transport.pendingCount(),
},
{
cpuThrottle,
profileCPU,
profileVisual,
minimal,
queuedDeltas: deltas.length,
historyTurns,
eventBatch,
},
)
await profile.reset()
})
})

View file

@ -0,0 +1,40 @@
import type { CDPSession, Page } from "@playwright/test"
export async function startTimelineProfile(page: Page, options: { cpuThrottle: number; profileCPU: boolean }) {
const cdp = await page.context().newCDPSession(page)
if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: options.cpuThrottle })
if (options.profileCPU) {
await cdp.send("Profiler.enable")
await cdp.send("Profiler.setSamplingInterval", { interval: 100 })
await cdp.send("Profiler.start")
}
return {
async stop() {
if (!options.profileCPU) return
const result = await cdp.send("Profiler.stop")
const self = new Map<number, number>()
result.profile.samples?.forEach((id, index) => {
const duration = (result.profile.timeDeltas?.[index] ?? 0) / 1_000
self.set(id, (self.get(id) ?? 0) + duration)
})
console.log(
"timeline cpu profile",
JSON.stringify(
result.profile.nodes
.map((node) => ({
function: node.callFrame.functionName || "(anonymous)",
url: node.callFrame.url,
line: node.callFrame.lineNumber + 1,
selfMs: self.get(node.id) ?? 0,
}))
.filter((node) => node.selfMs > 1)
.sort((a, b) => b.selfMs - a.selfMs)
.slice(0, 40),
),
)
},
async reset() {
if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 })
},
}
}

View file

@ -0,0 +1,547 @@
import type { Page } from "@playwright/test"
const STREAM_MARKER_PATTERN = "stream-(\\d+)"
const STREAM_FRAGMENT_COUNT = 18
type TimelineProbeState = {
started: number
ended: number
profileVisual: boolean
minimal: boolean
frames: number[]
frameAt: number[]
applied: { at: number; index: number }[]
geometry: {
scrollTop: number
scrollHeight: number
clientHeight: number
distance: number
virtualHeight: number
headerHeight: number
}[]
blanks: number
longTasks: number[]
layoutShifts: number[]
visibleMounts: number
visibleUnmounts: number
visibleRows: Set<Element>
visibleSubtreeMounts: string[]
visibleSubtreeUnmounts: string[]
visibleSubtreeReplacements: number
visibleSubtreeDropouts: string[]
visibleSubtrees: Map<string, Element>
subtreeKeys: WeakMap<Element, string>
maxOverlap: number
maxGap: number
maxPartTopMovement: number
previousPartTop: number
slowFrames: {
duration: number
index: number
phase: "stream" | "boundary" | "complete" | "unknown"
tokenSpans: number
blocks: number
codeBlocks: number
height: number
distance: number
}[]
scroll: {
calls: number
callNoops: number
sameFrameCalls: number
assignments: number
assignmentNoops: number
lastCallFrame: number
frame: number
}
row: HTMLElement
markdown: HTMLElement
running: boolean
previous: number
cleanup: () => void
start: () => void
}
export async function installTimelineStreamProbe(
page: Page,
options: { textPartID: string; finalIndex: number; profileVisual: boolean; minimal: boolean },
) {
await page.evaluate(
({ textPartID, finalIndex, profileVisual, minimal, markerPattern, fragmentCount }) => {
const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
const row = part?.closest<HTMLElement>("[data-timeline-row]")
const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
const root = part?.closest<HTMLElement>(".scroll-view__viewport")
if (!part || !row || !markdown || !root) throw new Error("missing streaming benchmark nodes")
const viewport = root.getBoundingClientRect()
const state: TimelineProbeState = {
started: 0,
ended: Infinity,
profileVisual,
minimal,
frames: [],
frameAt: [],
applied: [],
geometry: [],
blanks: 0,
longTasks: [],
layoutShifts: [],
visibleMounts: 0,
visibleUnmounts: 0,
visibleRows: new Set(
[...root.querySelectorAll("[data-timeline-key]")].filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > viewport.top && rect.top < viewport.bottom
}),
),
visibleSubtreeMounts: [],
visibleSubtreeUnmounts: [],
visibleSubtreeReplacements: 0,
visibleSubtreeDropouts: [],
visibleSubtrees: new Map<string, Element>(),
subtreeKeys: new WeakMap<Element, string>(),
maxOverlap: 0,
maxGap: 0,
maxPartTopMovement: 0,
previousPartTop: part.getBoundingClientRect().top,
slowFrames: [],
scroll: {
calls: 0,
callNoops: 0,
sameFrameCalls: 0,
assignments: 0,
assignmentNoops: 0,
lastCallFrame: -1,
frame: 0,
},
row,
markdown,
running: false,
previous: 0,
cleanup: () => {},
start: () => {},
}
;(window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark = state
const scrollTo = Element.prototype.scrollTo
const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
if (profileVisual) {
Element.prototype.scrollTo = function (...args) {
state.scroll.calls += 1
const top = typeof args[0] === "object" ? args[0]?.top : args[1]
if (typeof top === "number") {
const target = Math.min(top, this.scrollHeight - this.clientHeight)
if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
}
if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
state.scroll.lastCallFrame = state.scroll.frame
return scrollTo.apply(this, args)
}
Object.defineProperty(Element.prototype, "scrollTop", {
configurable: true,
get: scrollTop.get,
set(value) {
state.scroll.assignments += 1
if (Math.abs(this.scrollTop - value) < 1) state.scroll.assignmentNoops += 1
scrollTop.set!.call(this, value)
},
})
}
const recordLongTasks = (entries: PerformanceEntry[]) => {
if (!state.running) return
state.longTasks.push(
...entries
.filter((entry) => entry.startTime >= state.started && entry.startTime <= state.ended)
.map((entry) => entry.duration),
)
}
const longTaskObserver = new PerformanceObserver((list) => recordLongTasks(list.getEntries()))
longTaskObserver.observe({ type: "longtask" })
const recordLayoutShifts = (entries: PerformanceEntry[]) => {
if (!state.running) return
state.layoutShifts.push(
...entries
.map((entry) => {
const shift = entry as LayoutShiftEntry
if (shift.startTime < state.started || shift.hadRecentInput) return
return shift.value
})
.filter((value): value is number => value !== undefined),
)
}
const layoutShiftObserver = profileVisual
? new PerformanceObserver((list) => recordLayoutShifts(list.getEntries()))
: undefined
layoutShiftObserver?.observe({ type: "layout-shift", buffered: true })
const visible = (element: Element) => {
const rect = element.getBoundingClientRect()
const viewport = root.getBoundingClientRect()
const style = getComputedStyle(element)
return (
element.isConnected &&
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > viewport.top &&
rect.top < viewport.bottom &&
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) > 0
)
}
const critical = [
"[data-timeline-part-id]",
'[data-component="edit-content"]',
'[data-component="apply-patch-file-diff"]',
'[data-component="file"]',
'[data-component="markdown-code"]',
"[data-markdown-block]",
].join(",")
const describe = (element: Element) => {
const cached = state.subtreeKeys.get(element)
if (!element.isConnected && cached) return cached
const part = element.closest<HTMLElement>("[data-timeline-part-id]")?.dataset.timelinePartId ?? "unknown"
const block = element
.closest<HTMLElement>("[data-markdown-key]")
?.dataset.markdownKey?.replace(/:(?:code|full|live)$/, "")
const component =
element.getAttribute("data-component") ?? element.getAttribute("data-markdown-block") ?? element.tagName
const key = `${part}:${block ?? "root"}:${component}`
state.subtreeKeys.set(element, key)
return key
}
const recordMutations = (records: MutationRecord[]) => {
if (!state.running) return
records.forEach((record) => {
record.addedNodes.forEach((node) => {
if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && visible(node)) {
state.visibleMounts += 1
state.visibleRows.add(node)
}
if (!(node instanceof Element)) return
const added = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
added.forEach((element) => {
if (visible(element)) state.visibleSubtreeMounts.push(describe(element))
})
})
record.removedNodes.forEach((node) => {
if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && state.visibleRows.delete(node))
state.visibleUnmounts += 1
if (!(node instanceof Element)) return
const removed = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
removed.forEach((element) => {
const key = describe(element)
if (state.visibleSubtrees.get(key) === element) state.visibleSubtreeUnmounts.push(key)
})
})
})
}
const mutationObserver = profileVisual ? new MutationObserver(recordMutations) : undefined
mutationObserver?.observe(root, { childList: true, subtree: true })
const currentPart = () => root.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
const observeProgress = (at: number) => {
if (!state.running) return
const content = currentPart()?.textContent ?? ""
const index = content.includes("benchmark-complete")
? finalIndex
: Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
if (index >= 0 && index !== state.applied.at(-1)?.index) state.applied.push({ at, index })
}
const progressObserver = new MutationObserver(() => observeProgress(performance.now()))
progressObserver.observe(root, { characterData: true, childList: true, subtree: true })
state.cleanup = () => {
recordLongTasks(longTaskObserver.takeRecords())
recordLayoutShifts(layoutShiftObserver?.takeRecords() ?? [])
recordMutations(mutationObserver?.takeRecords() ?? [])
if (progressObserver.takeRecords().length) observeProgress(performance.now())
longTaskObserver.disconnect()
layoutShiftObserver?.disconnect()
mutationObserver?.disconnect()
progressObserver.disconnect()
if (!profileVisual) return
Element.prototype.scrollTo = scrollTo
Object.defineProperty(Element.prototype, "scrollTop", scrollTop)
}
const sample = (now: number) => {
if (!state.running) return
state.frameAt.push(now)
observeProgress(now)
if (minimal) {
state.frames.push(now - state.previous)
state.previous = now
requestAnimationFrame(sample)
return
}
setTimeout(() => {
if (!state.running) return
state.scroll.frame += 1
const duration = now - state.previous
state.frames.push(duration)
state.previous = now
const virtualRoot = root.querySelector<HTMLElement>("[data-timeline-virtual-content]")
const header = root.querySelector<HTMLElement>("[data-session-title]")
state.geometry.push({
scrollTop: root.scrollTop,
scrollHeight: root.scrollHeight,
clientHeight: root.clientHeight,
distance: root.scrollHeight - root.clientHeight - root.scrollTop,
virtualHeight: virtualRoot?.getBoundingClientRect().height ?? 0,
headerHeight: header?.getBoundingClientRect().height ?? 0,
})
const viewport = root.getBoundingClientRect()
if (profileVisual) {
const visibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
.filter((item) => item.rect.bottom > viewport.top && item.rect.top < viewport.bottom)
.sort((a, b) => a.rect.top - b.rect.top)
state.visibleRows = new Set(visibleRows.map((item) => item.element))
const rows = visibleRows.map((item) => item.rect)
rows.slice(1).forEach((rect, index) => {
const previous = rows[index]!
state.maxOverlap = Math.max(state.maxOverlap, previous.bottom - rect.top)
state.maxGap = Math.max(state.maxGap, rect.top - previous.bottom)
})
const partTop = part.getBoundingClientRect().top
state.maxPartTopMovement = Math.max(state.maxPartTopMovement, Math.abs(partTop - state.previousPartTop))
state.previousPartTop = partTop
}
const visibleRow = [...root.querySelectorAll<HTMLElement>("[data-timeline-row]")].some((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > viewport.top && rect.top < viewport.bottom
})
if (!visibleRow) state.blanks += 1
if (profileVisual) {
const subtrees = new Map<string, { element: Element; rendered: boolean }>()
const visibleSubtrees = new Map<string, Element>()
root.querySelectorAll(critical).forEach((element) => {
const key = describe(element)
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
const rendered =
element.isConnected &&
rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) > 0
subtrees.set(key, { element, rendered })
if (rendered && rect.bottom > viewport.top && rect.top < viewport.bottom) {
const previous = state.visibleSubtrees.get(key)
if (previous && previous !== element && key.startsWith(`${textPartID}:`))
state.visibleSubtreeReplacements += 1
visibleSubtrees.set(key, element)
}
})
state.visibleSubtrees.forEach((element, key) => {
const current = subtrees.get(key)
if (key.startsWith(`${textPartID}:`) && !current?.rendered) {
const markdown = part.querySelector<HTMLElement>('[data-component="markdown"]')
state.visibleSubtreeDropouts.push(
`${key}:projection=${markdown?.dataset.markdownProjectionLength}/${markdown?.dataset.markdownProjectionBlocks}:result=${markdown?.dataset.markdownResultLength}/${markdown?.dataset.markdownResultBlocks}:applied=${markdown?.dataset.markdownAppliedBlocks}:dom=${markdown?.children.length}`,
)
}
if (element.matches('[data-component="file"]')) {
const hadLines = element.hasAttribute("data-profiler-had-lines")
const hasLines = element.shadowRoot?.querySelector("[data-line]") != null
if (hasLines) element.setAttribute("data-profiler-had-lines", "")
if (hadLines && !hasLines) state.visibleSubtreeDropouts.push(`${key}:shadow-lines`)
}
})
state.visibleSubtrees = visibleSubtrees
}
if (profileVisual && duration > 33.34) {
const livePart = currentPart()
const content = livePart?.textContent ?? ""
const complete = content.includes("benchmark-complete")
const index = complete
? finalIndex
: Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
state.slowFrames.push({
duration,
index,
phase: complete
? "complete"
: index >= 0 && index % fragmentCount === 0
? "boundary"
: index >= 0
? "stream"
: "unknown",
tokenSpans: livePart?.querySelectorAll(".shiki span").length ?? 0,
blocks: livePart?.querySelectorAll("[data-markdown-block]").length ?? 0,
codeBlocks: livePart?.querySelectorAll('[data-component="markdown-code"]').length ?? 0,
height: livePart?.getBoundingClientRect().height ?? 0,
distance: root.scrollHeight - root.clientHeight - root.scrollTop,
})
}
requestAnimationFrame(sample)
}, 0)
}
state.start = () => {
state.started = performance.now()
state.previous = state.started
state.running = true
requestAnimationFrame(sample)
}
},
{ ...options, markerPattern: STREAM_MARKER_PATTERN, fragmentCount: STREAM_FRAGMENT_COUNT },
)
}
export function startTimelineStreamProbe(page: Page) {
return page.evaluate(() => {
const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
if (!state) throw new Error("missing streaming benchmark state")
state.start()
})
}
type LayoutShiftEntry = PerformanceEntry & { value: number; hadRecentInput?: boolean }
export function layoutShiftValue(
entry: Pick<LayoutShiftEntry, "startTime" | "value" | "hadRecentInput">,
start: number,
) {
if (entry.startTime < start || entry.hadRecentInput) return
return entry.value
}
export function removeVisibleRow<T>(visible: Set<T>, row: T) {
return visible.delete(row)
}
export function streamProgress(content: string) {
const index = Number(content.match(new RegExp(STREAM_MARKER_PATTERN, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
return {
index,
phase: content.includes("benchmark-complete")
? ("complete" as const)
: index >= 0 && index % STREAM_FRAGMENT_COUNT === 0
? ("boundary" as const)
: index >= 0
? ("stream" as const)
: ("unknown" as const),
}
}
export async function collectTimelineStreamMetrics(
page: Page,
options: { textPartID: string; finalIndex: number; navigations: string[] },
) {
return page.evaluate(({ textPartID, finalIndex, navigations }) => {
const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
if (!state) throw new Error(`missing streaming benchmark state after navigation: ${JSON.stringify(navigations)}`)
state.ended = performance.now()
state.cleanup()
state.running = false
const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
const row = part?.closest<HTMLElement>("[data-timeline-row]")
const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
const sorted = state.frames.slice().sort((a, b) => a - b)
const duration = state.frames.reduce((sum, value) => sum + value, 0)
const longestSlowStreak = state.frames.reduce(
(result, value) => {
const current = value > 33.34 ? result.current + 1 : 0
return { current, longest: Math.max(result.longest, current) }
},
{ current: 0, longest: 0 },
).longest
const busyStart = state.applied.at(0)?.at
const completion = state.applied.find((value) => value.index === finalIndex)
const busyEnd = completion?.at
const busyFrames =
busyStart === undefined || busyEnd === undefined
? []
: state.frames.filter((_, index) => state.frameAt[index]! >= busyStart && state.frameAt[index]! <= busyEnd)
const busySorted = busyFrames.slice().sort((a, b) => a - b)
const busyDuration = busyFrames.reduce((sum, value) => sum + value, 0)
const completionObservedMs = (completion?.at ?? NaN) - state.started
const visual = state.profileVisual
? {
layoutShiftValueSum: state.layoutShifts.reduce((sum, value) => sum + value, 0),
maxLayoutShiftValue: Math.max(0, ...state.layoutShifts),
visibleMounts: state.visibleMounts,
visibleUnmounts: state.visibleUnmounts,
visibleSubtreeMounts: state.visibleSubtreeMounts,
visibleSubtreeUnmounts: [...new Set(state.visibleSubtreeUnmounts)],
visibleSubtreeReplacements: state.visibleSubtreeReplacements,
visibleSubtreeDropouts: [...new Set(state.visibleSubtreeDropouts)],
maxOverlapPx: state.maxOverlap,
maxGapPx: state.maxGap,
maxPartTopMovementPx: state.maxPartTopMovement,
slowestRafGaps: state.slowFrames
.sort((a, b) => b.duration - a.duration)
.slice(0, 20)
.map((frame) => ({
durationMs: frame.duration,
index: frame.index,
phase: frame.phase,
tokenSpans: frame.tokenSpans,
blocks: frame.blocks,
codeBlocks: frame.codeBlocks,
heightPx: frame.height,
distancePx: frame.distance,
})),
slowRafGapPhases: Object.fromEntries(
["stream", "boundary", "complete", "unknown"].map((phase) => {
const frames = state.slowFrames.filter((frame) => frame.phase === phase)
return [
phase,
{
count: frames.length,
totalMs: frames.reduce((sum, frame) => sum + frame.duration, 0),
maxMs: Math.max(0, ...frames.map((frame) => frame.duration)),
},
]
}),
),
scroll: state.scroll,
}
: null
const geometry = state.minimal
? null
: {
maxDistancePx: Math.max(0, ...state.geometry.map((sample) => sample.distance)),
finalDistancePx: state.geometry.at(-1)?.distance ?? 0,
final: state.geometry.at(-1),
distanceTransitionsPx: state.geometry
.map((sample) => Math.round(sample.distance))
.filter((value, index, values) => index === 0 || value !== values[index - 1]),
bottomDriftTransitions: state.geometry.slice(1).filter((value, index) => {
const previous = state.geometry[index]?.distance ?? 0
return previous <= 1 && value.distance > 1
}).length,
blankSamples: state.blanks,
}
return {
capabilities: { visual: state.profileVisual, geometry: !state.minimal },
completionObservedMs,
deltasPerSecond: Number.isFinite(completionObservedMs) ? finalIndex / (completionObservedMs / 1_000) : null,
rafGapSamples: state.frames.length,
rafCallbackRate: duration ? (state.frames.length * 1000) / duration : 0,
observedProgressWindowRafCallbackRate: busyDuration ? (busyFrames.length * 1000) / busyDuration : null,
observedProgressWindowRafGapP95Ms: busySorted[Math.floor(busySorted.length * 0.95)] ?? null,
observedProgressWindowRafGaps: busyFrames.length,
maxObservedProgressIndex: Math.max(-1, ...state.applied.map((value) => value.index)),
observedProgressTransitions: state.applied.length,
rafGapP50Ms: sorted[Math.floor(sorted.length * 0.5)] ?? 0,
rafGapP95Ms: sorted[Math.floor(sorted.length * 0.95)] ?? 0,
rafGapP99Ms: sorted[Math.floor(sorted.length * 0.99)] ?? 0,
maxRafGapMs: sorted.at(-1) ?? 0,
rafGapsOver33Ms: state.frames.filter((value) => value > 33.34).length,
rafGapsOver50Ms: state.frames.filter((value) => value > 50).length,
missedFrameBudgetEquivalents: state.frames.reduce(
(sum, value) => sum + Math.max(0, Math.round(value / 16.67) - 1),
0,
),
longestRafGapOver33MsStreak: longestSlowStreak,
longTaskCount: state.longTasks.length,
longTaskTimeMs: state.longTasks.reduce((sum, value) => sum + value, 0),
visual,
geometry,
rowReplaced: row !== state.row,
markdownReplaced: markdown !== state.markdown,
domTextCharacters: part?.textContent?.length ?? 0,
}
}, options)
}

View file

@ -0,0 +1,335 @@
const words = [
"alpha",
"bravo",
"charlie",
"delta",
"echo",
"foxtrot",
"golf",
"hotel",
"india",
"juliet",
"kilo",
"lima",
"metro",
"nova",
"orbit",
"pixel",
"quartz",
"river",
"signal",
"vector",
]
const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
type Message = { info: MessageInfo; parts: MessagePart[] }
function lorem(seed: number, length: number) {
let out = ""
let i = seed
while (out.length < length) {
const word = words[i % words.length]
out += (out ? " " : "") + word
if (i % 17 === 0) out += ".\n\n"
i += 7
}
return out.slice(0, length)
}
function id(prefix: string, value: number) {
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
}
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
const messageID = id("msg_user", index)
return {
info: {
id: messageID,
sessionID,
role: "user",
time: { created: 1700000000000 + index * 10_000 },
summary: { diffs },
agent: "build",
model,
},
parts: [
{
id: id("prt_user_text", index),
sessionID,
messageID,
type: "text",
text: lorem(index, textLength),
},
],
}
}
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
const messageID = id("msg_assistant", index)
return {
info: {
id: messageID,
sessionID,
role: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
parentID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
finish: "stop",
},
parts: parts.map((part) => ({
...part,
sessionID,
messageID,
})),
}
}
function textPart(index: number, partIndex: number, length: number): MessagePart {
const prose = lorem(index * 13 + partIndex, length)
const text =
index % 12 === 0
? `${prose}\n\n\`\`\`ts\n${code(index, 80)}\n\`\`\``
: index % 5 === 0
? `${prose}\n\n\`\`\`ts\nexport const value = "${lorem(index, 220)}"\n\`\`\``
: index % 7 === 0
? `${prose}\n\nThe wrapped inline value is \`${lorem(index, 180)}\`.`
: prose
return { id: id(`prt_text_${partIndex}`, index), type: "text", text }
}
function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
return {
id: id(`prt_reasoning_${partIndex}`, index),
type: "reasoning",
text: lorem(index * 19 + partIndex, length),
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
}
}
function toolPart(
index: number,
partIndex: number,
tool: string,
input: Record<string, unknown>,
outputLength = 160,
): MessagePart {
const metadata =
tool === "apply_patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write"
? {
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
diff: patch(index, outputLength),
preview: patch(index + 1, 420),
}
: tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] }
: {}
return {
id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool",
callID: id("call", index * 10 + partIndex),
tool,
state: {
status: "completed",
input,
output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
metadata,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
},
}
}
function patchFile(seed: number, type: "add" | "update" | "delete") {
return {
filePath: `src/generated/patch-${seed}.ts`,
relativePath: `src/generated/patch-${seed}.ts`,
type,
additions: (seed % 7) + 1,
deletions: type === "add" ? 0 : seed % 4,
patch: patch(seed, 520),
before: type === "add" ? undefined : code(seed, 18),
after: type === "delete" ? undefined : code(seed + 1, 24),
}
}
function fileDiff(file: string, seed: number) {
const lines = seed % 12 === 0 ? 300 : seed % 8 === 0 ? 2 : 38
const before = code(seed, lines, seed % 10 === 0 ? 280 : 32)
const after =
lines === 2
? before.replace("value1", "updatedValue1")
: lines === 300
? code(seed + 1, lines, seed % 10 === 0 ? 280 : 32)
: before.replace("value4", "updatedValue4").replace("value20", "updatedValue20")
return {
file,
additions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
deletions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
before,
after,
}
}
function patch(seed: number, length: number) {
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
}
function code(seed: number, lines: number, width = 32) {
return Array.from(
{ length: lines },
(_, index) => `export const value${index} = "${lorem(seed + index, width)}"`,
).join("\n")
}
function turn(index: number): Message[] {
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
const parts = [
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
...(index % 3 === 0
? [
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
]
: []),
textPart(index, 2, 160 + (index % 6) * 90),
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
...(index % 6 === 0
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []),
...(index % 7 === 0
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
...(index % 13 === 0
? [
toolPart(
index,
11,
"question",
{ questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
120,
),
]
: []),
...(index % 17 === 0
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
: []),
]
return [user, assistantMessage(targetID, index, user.info.id, parts)]
}
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
userMessage(sourceID, index + 1000, 120),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
]).flat()
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim()
if (part.type === "reasoning") return !!part.text.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
}
function orderedParts(message: Message) {
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}
export const fixture = {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "smoke-project",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [
{
id: sourceID,
slug: "source",
projectID,
directory,
title: "Uncommitted changes inquiry",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
{
id: targetID,
slug: "target",
projectID,
directory,
title: "Example Game: sample jump movement & sample physics analysis",
version: "dev",
time: { created: 1700000001000, updated: 1700000001000 },
},
],
sourceID,
targetID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
expected: {
sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis",
sourceMessageIDs: sourceMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetMessageIDs: targetMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) =>
orderedParts(message)
.filter(renderable)
.map((part) => part.id),
),
},
}
export function pageMessages(sessionID: string, limit: number, before?: string) {
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
const end = before
? Math.max(
0,
messages.findIndex((message) => message.info.id === before),
)
: messages.length
const start = Math.max(0, end - limit)
return {
items: messages.slice(start, end),
cursor: start > 0 ? messages[start]!.info.id : undefined,
}
}

View file

@ -0,0 +1,67 @@
import type { Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { fixture } from "./session-timeline-stress.fixture"
export async function installTimelineSettings(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
})
}
export function mockStressTimeline(page: Page) {
return mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
})
}
export async function installStressSessionTabs(page: Page) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ directory, sourceID, targetID, dirBase64, server }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
server,
dirBase64,
sessionId,
})),
),
)
},
{
directory: fixture.directory,
sourceID: fixture.sourceID,
targetID: fixture.targetID,
dirBase64: base64Encode(fixture.directory),
server,
},
)
}
export function stressSessionHref(sessionID: string) {
return `/${base64Encode(fixture.directory)}/session/${sessionID}`
}

View file

@ -0,0 +1,15 @@
import { expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import path from "node:path"
import os from "node:os"
import { prepareChromeTrace } from "../chrome-trace"
test("creates the configured trace directory", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-trace-"))
try {
const file = await prepareChromeTrace(path.join(root, "nested", "traces"), "session/tab", false, "test")
expect(file).toEndWith("-session-tab-458ed9e3-test.json")
} finally {
await rm(root, { recursive: true, force: true })
}
})

View file

@ -0,0 +1,42 @@
import { expect, test } from "bun:test"
import { compressCachedRepaintTrace, layoutShiftSample } from "../timeline/session-tab-repaint-probe"
test("compresses repeated repaint states without losing frame samples", () => {
const state = {
root: 1,
scrollTop: 10,
scrollHeight: 20,
bottomErrorPx: 0,
last: true,
rows: [{ key: "row", node: 2, top: 0, bottom: 10 }],
mounted: 1,
center: "content",
}
const trace = {
timeOriginEpochMs: 1_000,
startedAtPerformanceMs: 100,
samples: [
{ observedAtMs: 16, ...state, destination: ["target"], source: [] },
{ observedAtMs: 32, ...state, destination: ["target"], source: [] },
{ observedAtMs: 48, ...state, scrollTop: 11, destination: ["target"], source: [] },
],
mutations: [{ observedAtMs: 20, changed: [{ type: "add", node: 2 }] }],
shifts: [{ occurredAtMs: 24, value: 0.1 }],
windowMs: 1_000,
running: false,
stop() {},
}
const compressed = compressCachedRepaintTrace(trace)
const samples = compressed.samples.flatMap((group) =>
group.observedAtMs.map((observedAtMs) => ({ observedAtMs, ...group.state })),
)
expect(samples).toEqual(trace.samples)
expect(compressed.mutations).toEqual(trace.mutations)
expect(compressed.shifts).toEqual(trace.shifts)
})
test("records layout shifts at occurrence time within the probe window", () => {
expect(layoutShiftSample({ startTime: 99, value: 0.1 }, 100)).toBeUndefined()
expect(layoutShiftSample({ startTime: 124, value: 0.2 }, 100)).toEqual({ occurredAtMs: 24, value: 0.2 })
})

View file

@ -0,0 +1,54 @@
import { expect, test } from "bun:test"
import { classifySessionSwitch } from "../timeline/session-tab-switch-metrics"
test("counts source and blank samples before the destination is observed", () => {
const result = classifySessionSwitch([
{ observedAtMs: 16, destination: [], source: ["source"], hasVisibleRows: true, last: false },
{ observedAtMs: 32, destination: [], source: [], hasVisibleRows: false, last: false },
{ observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
{ observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
{ observedAtMs: 80, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
])
expect(result.blankSamples).toBe(1)
expect(result.sourceSamples).toBe(1)
expect(result.unknownSamples).toBe(0)
expect(result.firstDestinationObservedMs).toBe(48)
expect(result.stableObservedMs).toBe(80)
})
test("does not classify mixed source and destination content as correct", () => {
const result = classifySessionSwitch([
{
observedAtMs: 16,
destination: ["destination"],
source: ["source"],
hasVisibleRows: true,
last: true,
bottomErrorPx: 0,
},
{ observedAtMs: 32, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
{ observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
{ observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
])
expect(result.firstCorrectObservedMs).toBe(32)
expect(result.stableObservedMs).toBe(64)
})
test("reports missing correctness without throwing", () => {
const result = classifySessionSwitch([
{
observedAtMs: 16,
destination: ["destination"],
source: ["source"],
hasVisibleRows: true,
last: true,
bottomErrorPx: 0,
},
])
expect(result.firstDestinationObservedMs).toBe(16)
expect(result.firstCorrectObservedMs).toBeNull()
expect(result.stableObservedMs).toBeNull()
})

View file

@ -0,0 +1,14 @@
import { expect, test } from "bun:test"
import { streamChunk } from "../timeline/session-timeline-benchmark.fixture"
import { streamProgress } from "../timeline/session-timeline-stream-probe"
test("classifies emitted stream markers using the fixture cycle", () => {
expect(streamProgress("before stream-17 after stream-18")).toEqual({ index: 18, phase: "boundary" })
expect(streamProgress("before stream-18 after stream-19")).toEqual({ index: 19, phase: "stream" })
expect(streamProgress("benchmark-complete stream-36")).toEqual({ index: 36, phase: "complete" })
expect(streamProgress("no marker")).toEqual({ index: -1, phase: "unknown" })
})
test("emits progress markers at fixture boundaries", () => {
expect(streamProgress(streamChunk(18, 160))).toEqual({ index: 18, phase: "boundary" })
})

View file

@ -0,0 +1,16 @@
import { expect, test } from "bun:test"
import { layoutShiftValue, removeVisibleRow } from "../timeline/session-timeline-stream-probe"
test("excludes layout shifts before the probe window and recent input", () => {
expect(layoutShiftValue({ startTime: 9, value: 0.1 }, 10)).toBeUndefined()
expect(layoutShiftValue({ startTime: 10, value: 0.2, hadRecentInput: true }, 10)).toBeUndefined()
expect(layoutShiftValue({ startTime: 11, value: 0.3 }, 10)).toBe(0.3)
})
test("classifies removed rows from their last painted visibility", () => {
const row = {}
const visible = new Set([row])
expect(removeVisibleRow(visible, row)).toBe(true)
expect(removeVisibleRow(visible, row)).toBe(false)
})

View file

@ -21,6 +21,7 @@ const words = [
"vector", "vector",
] ]
const serverKey = "http://127.0.0.1:4096"
const sourceID = "ses_smoke_source" const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target" const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject" const directory = "C:/OpenCode/SmokeProject"
@ -139,7 +140,7 @@ function toolPart(
status: "completed", status: "completed",
input, input,
output: lorem(index * 23 + partIndex, outputLength), output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed", title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
metadata, metadata,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 }, time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
}, },
@ -200,9 +201,7 @@ function turn(index: number): Message[] {
...(index % 8 === 0 ...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []), : []),
...(index % 7 === 0 ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []), ...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
...(index % 13 === 0 ...(index % 13 === 0
@ -242,6 +241,7 @@ function orderedParts(message: Message) {
export const fixture = { export const fixture = {
directory, directory,
serverKey,
project: { project: {
id: projectID, id: projectID,
worktree: directory, worktree: directory,
@ -295,6 +295,7 @@ export const fixture = {
.filter(renderable) .filter(renderable)
.map((part) => part.id), .map((part) => part.id),
), ),
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
}, },
} }

View file

@ -327,6 +327,18 @@ test.describe("smoke: session timeline", () => {
const expectedMessageIDs = fixture.expected.targetMessageIDs const expectedMessageIDs = fixture.expected.targetMessageIDs
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors) await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors) await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(shellSubtitle).toHaveCount(0)
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
await shellTrigger.click()
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
await expect(shellSubtitle).toHaveText("bun typecheck")
await shellTrigger.click()
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
await expect(shellSubtitle).toHaveCount(0)
}) })
}) })
@ -706,7 +718,8 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
} }
async function switchTitlebarSession(page: Page, sessionID: string, title: string) { async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
const href = `/${base64Encode(fixture.directory)}/session/${sessionID}` console.log(process.env)
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible() await expect(tab).toBeVisible()
await tab.click() await tab.click()

View file

@ -44,7 +44,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
await page.route("**/*", async (route) => { await page.route("**/*", async (route) => {
const url = new URL(route.request().url()) const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
if (url.port !== targetPort) return route.fallback() const appPort = new URL(
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
).port
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
const path = url.pathname const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
@ -72,7 +75,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined) return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
} }
return json(route, {}) if (url.port === targetPort && targetPort !== appPort) return json(route, {})
return route.fallback()
}) })
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.17.8", "version": "1.17.9",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@ -24,7 +24,8 @@
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:local": "playwright test", "test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report" "test:e2e:report": "playwright show-report e2e/playwright-report",
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {

View file

@ -9,6 +9,7 @@ const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
export default defineConfig({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
testIgnore: process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
outputDir: "./e2e/test-results", outputDir: "./e2e/test-results",
timeout: 60_000, timeout: 60_000,
expect: { expect: {

View file

@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router" import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { keepPreviousData, QueryClient, QueryClientProvider, useQuery } from "@tanstack/solid-query"
import { Effect } from "effect" import { Effect } from "effect"
import { import {
type Component, type Component,
@ -30,7 +30,7 @@ import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command" import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk" import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync" import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider } from "@/context/global" import { GlobalProvider } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights" import { HighlightsProvider } from "@/context/highlights"
@ -47,11 +47,14 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk" import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context" import { WslServersProvider } from "@/wsl/context"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout" import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route"
const HomeRoute = lazy(() => import("@/pages/home")) const LegacyHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome })))
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome })))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
const NewSession = lazy(() => import("@/pages/new-session")) const NewSession = lazy(() => import("@/pages/new-session"))
@ -64,6 +67,10 @@ const SessionRoute = Object.assign(
const server = useServer() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
if (params.id && settings.general.newLayoutDesigns()) {
return <Navigate href={sessionHref(server.key, params.id)} />
}
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id) // 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=… // is replaced by a draft at /new-session?draftId=…
createEffect(() => { createEffect(() => {
@ -82,29 +89,55 @@ const SessionRoute = Object.assign(
{ preload: Session.preload }, { preload: Session.preload },
) )
const TargetSessionRoute = Object.assign(
() => {
const sdk = useSDK()
const serverSDK = useServerSDK()
return (
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
<SessionProviders>
<Session />
</SessionProviders>
</Show>
)
},
{ preload: Session.preload },
)
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected // 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/ // server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server. // Notification/Models + the visual Layout) for that server.
function SelectedServerLayout(props: ParentProps) { function SelectedServerProviders(props: ParentProps) {
return ( return (
<ServerKey> <ServerKey>
<ServerSDKProvider> <ServerSDKProvider>
<ServerSyncProvider> <ServerSyncProvider>{props.children}</ServerSyncProvider>
<ServerScopedShell>{props.children}</ServerScopedShell>
</ServerSyncProvider>
</ServerSDKProvider> </ServerSDKProvider>
</ServerKey> </ServerKey>
) )
} }
function LegacyServerLayout(props: ParentProps) {
return (
<SelectedServerProviders>
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
</SelectedServerProviders>
)
}
// Wraps /new-session. It resolves the draft's target server and provides the // 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 // server-scoped shell for that server — without ServerKey, so the page never depends
// on the globally "selected" server. // on the globally "selected" server.
function DraftServerLayout(props: ParentProps) { function TargetServerLayout(props: ParentProps) {
const server = useServer() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
const params = useParams<{ serverKey?: string }>()
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const conn = createMemo(() => { const conn = createMemo(() => {
if (params.serverKey) {
const key = requireServerKey(params.serverKey)
return server.list.find((item) => ServerConnection.key(item) === key)
}
const id = search.draftId const id = search.draftId
if (!id) return undefined if (!id) return undefined
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id) const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
@ -115,48 +148,98 @@ function DraftServerLayout(props: ParentProps) {
return ( return (
<ServerSDKProvider server={conn}> <ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}> <ServerSyncProvider server={conn}>
<ServerScopedShell>{props.children}</ServerScopedShell> <TargetDirectoryLayout>{props.children}</TargetDirectoryLayout>
</ServerSyncProvider> </ServerSyncProvider>
</ServerSDKProvider> </ServerSDKProvider>
) )
} }
function TargetDirectoryLayout(props: ParentProps) {
const params = useParams<{ serverKey?: string; id?: string }>()
const [search] = useSearchParams<{ draftId?: string }>()
const settings = useSettings()
const tabs = useTabs()
const serverSDK = useServerSDK()
const serverKey = createMemo(() => {
if (params.serverKey) return requireServerKey(params.serverKey)
if (!search.draftId) return undefined
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.server
})
const resolved = useQuery(() => ({
queryKey: [serverSDK().scope, "session-route", params.id] as const,
enabled: !!params.serverKey && !!params.id,
placeholderData: keepPreviousData,
queryFn: async () => {
const session = (await serverSDK().client.session.get({ sessionID: params.id! })).data!
const root = await rootSession(session, (sessionID) =>
serverSDK()
.client.session.get({ sessionID })
.then((result) => result.data!),
)
return { session, rootID: root.id }
},
}))
const resolvedDirectory = createMemo(() => {
if (params.serverKey) return resolved.data?.session.directory
if (!search.draftId) return undefined
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory
})
const directory = createMemo<string | undefined>((prev) => prev ?? resolvedDirectory())
const home = () => !params.serverKey && !search.draftId
const targetDirectory = () => directory()!
createEffect(() => {
const current = resolved.data
const key = serverKey()
if (!current || !key) return
tabs.addSessionTab({
server: key,
sessionId: current.rootID,
})
})
return (
<NewServerScopedShell directory={() => (home() ? undefined : directory())} sessionID={() => params.id}>
<Show when={!home()} fallback={props.children}>
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
<Show when={directory()}>
<Show
when={!params.serverKey || settings.general.newLayoutDesigns()}
fallback={<Navigate href={legacySessionHref(directory()!, params.id!)} />}
>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<Show when={!params.serverKey || (resolved.data && !resolved.isPlaceholderData)}>
{props.children}
</Show>
</DirectoryDataProvider>
</SDKProvider>
</Show>
</Show>
</Show>
</Show>
</NewServerScopedShell>
)
}
function DraftRoute() { function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs() const tabs = useTabs()
return ( return (
<Show when={tabs.ready()}> <Show when={tabs.ready()}>
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}> <Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
{(draftID) => <ResolvedDraftRoute draftID={draftID} />} <ResolvedDraftRoute />
</Show> </Show>
</Show> </Show>
) )
} }
function ResolvedDraftRoute(props: { draftID: string }) { function ResolvedDraftRoute() {
const tabs = useTabs()
const draft = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
)
// Key on the directory so retargeting the draft's project re-instantiates the
// directory-scoped providers while keeping the same draft id. The draft's target
// server is provided by DraftServerLayout, so changing only the server updates the
// SDK/sync hooks without remounting the composer.
const directory = () => draft()?.directory
return ( return (
<Show when={directory()} keyed> <DraftProviders>
{(dir) => ( <NewSession />
<SDKProvider directory={dir}> </DraftProviders>
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
)}
</Show>
) )
} }
@ -210,32 +293,51 @@ function BodyDesignClass() {
// shell (router root) so they stay mounted regardless of the active server/route. // shell (router root) so they stay mounted regardless of the active server/route.
function SharedProviders(props: ParentProps) { function SharedProviders(props: ParentProps) {
return ( return (
<SettingsProvider> <>
<BodyDesignClass /> <BodyDesignClass />
<CommandProvider> <CommandProvider>
<HighlightsProvider>{props.children}</HighlightsProvider> <HighlightsProvider>{props.children}</HighlightsProvider>
</CommandProvider> </CommandProvider>
</SettingsProvider> </>
) )
} }
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside // 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 // each per-route server layout so they resolve to that route's server (selected vs
// draft). The Layout remounts when crossing between those groups. // draft). The Layout remounts when crossing between those groups.
function ServerScopedShell(props: ParentProps) { type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
sessionID?: () => string | undefined
}>
function ServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<PermissionProvider> <PermissionProvider directory={props.directory}>
<LayoutProvider> <LayoutProvider>
<NotificationProvider> <NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider> <ModelsProvider>{props.children}</ModelsProvider>
<Layout>{props.children}</Layout>
</ModelsProvider>
</NotificationProvider> </NotificationProvider>
</LayoutProvider> </LayoutProvider>
</PermissionProvider> </PermissionProvider>
) )
} }
function LegacyServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
<LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders>
)
}
function NewServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
<NewLayout>{props.children}</NewLayout>
</ServerScopedProviders>
)
}
function SessionProviders(props: ParentProps) { function SessionProviders(props: ParentProps) {
return ( return (
<TerminalProvider> <TerminalProvider>
@ -439,28 +541,61 @@ export function AppInterface(props: {
servers={props.servers} servers={props.servers}
> >
<GlobalProvider> <GlobalProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}> <SettingsProvider>
<Dynamic <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
component={props.router ?? Router} <Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
root={(routerProps) => ( <Dynamic
<TabsProvider> component={props.router ?? Router}
<ServerShell>{routerProps.children}</ServerShell> root={(routerProps) => (
</TabsProvider> <TabsProvider>
)} <ServerShell>{routerProps.children}</ServerShell>
> </TabsProvider>
<Route component={SelectedServerLayout}> )}
<Route path="/" component={HomeRoute} /> >
<Route path="/:dir" component={DirectoryLayout}> <Routes />
<Route path="/" component={() => <Navigate href="session" />} /> </Dynamic>
<Route path="/session/:id?" component={SessionRoute} /> </Show>
</Route> </ConnectionGate>
</Route> </SettingsProvider>
<Route component={DraftServerLayout}>
<Route path="/new-session" component={DraftRoute} />
</Route>
</Dynamic>
</ConnectionGate>
</GlobalProvider> </GlobalProvider>
</ServerProvider> </ServerProvider>
) )
} }
function Routes() {
const settings = useSettings()
return (
<>
<Route component={LegacyServerLayout}>
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Route>
<Route component={TargetServerLayout}>
<Show when={settings.general.newLayoutDesigns()}>
{
<>
<Route path="/" component={NewHome} />
<Route path="/:dir" component={DirectoryLayout}>
<Route
path="/session/:id"
component={() => {
const server = useServer()
const { id } = useParams()
return <Navigate href={`/server/${server.key}/session/${id}`} />
}}
/>
</Route>
</>
}
</Show>
<Route path="/new-session" component={DraftRoute} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</Route>
</>
)
}

View file

@ -388,12 +388,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
local.session.promote(sessionDirectory, session.id) local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId const draftID = search.draftId
if (draftID) if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
tabs.promoteDraft(draftID, {
server: server.key,
dirBase64: base64Encode(sessionDirectory),
sessionId: session.id,
})
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
} }
} }

View file

@ -29,7 +29,6 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu" import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history" import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useServerSync } from "@/context/server-sync"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
@ -39,10 +38,11 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { decode64 } from "@/utils/base64"
import { ServerConnection, useServer } from "@/context/server" import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs, type Tab } from "@/context/tabs" import { tabHref, useTabs } from "@/context/tabs"
import "./titlebar.css" import "./titlebar.css"
import { useServerSDK } from "@/context/server-sdk"
import { Session } from "@opencode-ai/sdk/v2"
type TauriDesktopWindow = { type TauriDesktopWindow = {
startDragging?: () => Promise<void> startDragging?: () => Promise<void>
@ -264,7 +264,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<Switch> <Switch>
<Match when={useV2Titlebar()}> <Match when={useV2Titlebar()}>
{(_) => { {(_) => {
const serverSync = useServerSync() const serverSdk = useServerSDK()
const navigate = useNavigate() const navigate = useNavigate()
const layout = useLayout() const layout = useLayout()
@ -280,6 +280,17 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const tabs = useTabs() const tabs = useTabs()
const tabsStore = tabs.store const tabsStore = tabs.store
const tabsStoreActions = tabs const tabsStoreActions = tabs
const [session] = createResource(
() => {
const route = layout.route()
return route.type === "session" ? route : undefined
},
(route) =>
serverSdk()
.client.session.get({ sessionID: route.sessionId })
.then((x) => x.data)
.catch(() => {}),
)
const matchRoute = (route: LayoutRoute) => { const matchRoute = (route: LayoutRoute) => {
if (route.type === "home") return if (route.type === "home") return
@ -292,10 +303,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId, item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
) )
if (main) return main if (main) return main
const sync = serverSync().createDirSyncContext(route.dir) const s = session()
const session = sync.session.get(route.sessionId) if (s?.parentID) {
if (session?.parentID) { const parentID = s.parentID
const parentID = session.parentID
const parent = tabsStore.find( const parent = tabsStore.find(
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID, (item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
) )
@ -316,15 +326,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
} }
if (route.type === "session") { if (route.type === "session") {
const sync = serverSync().createDirSyncContext(route.dir) const s = session()
const session = sync.session.get(route.sessionId) if (!s) return
if (!session) return const sessionId = s.parentID ?? s.id
const sessionId = session.parentID ?? session.id const next = { server: route.server ?? server.key, sessionId }
const next = {
server: route.server ?? server.key,
dirBase64: route.dirBase64,
sessionId,
}
tabsStoreActions.addSessionTab(next) tabsStoreActions.addSessionTab(next)
} }
}) })
@ -509,25 +514,38 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
) )
} }
const [session] = createResource(
() => tab.sessionId,
(sessionID) =>
serverSdk()
.client.session.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined),
)
return ( return (
<> <>
{divider()} {divider()}
<TabNavItem <Show when={session()}>
ref={ref} {(session) => (
href={tabHref(tab)} <TabNavItem
server={tab.server} ref={ref}
directory={decode64(tab.dirBase64)!} href={tabHref(tab)}
sessionId={tab.sessionId} server={tab.server}
onNavigate={() => { sessionId={tab.sessionId}
tabs.select(tab) session={session()}
onNavigate={() => {
tabs.select(tab)
ref.scrollIntoView({ behavior: "instant" }) ref.scrollIntoView({ behavior: "instant" })
}} }}
onClose={() => tabsStoreActions.removeTab(i())} onClose={() => tabsStoreActions.removeTab(i())}
active={currentTab() === tab} active={currentTab() === tab}
activeServer={tab.server === server.key} activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()} forceTruncate={tabsAreOverflowing()}
/> />
)}
</Show>
</> </>
) )
}} }}
@ -807,7 +825,6 @@ function TabNavItem(props: {
ref?: HTMLDivElement ref?: HTMLDivElement
href: string href: string
server: ServerConnection.Key server: ServerConnection.Key
directory: string
sessionId?: string sessionId?: string
hideClose?: boolean hideClose?: boolean
onClose: () => void onClose: () => void
@ -815,31 +832,19 @@ function TabNavItem(props: {
active?: boolean active?: boolean
activeServer: boolean activeServer: boolean
forceTruncate?: boolean forceTruncate?: boolean
session: Session
}) { }) {
const closeTab = (event: MouseEvent) => { const closeTab = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
props.onClose() props.onClose()
} }
const global = useGlobal() const global = useGlobal()
const serverCtx = createMemo(() => { const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server) const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.createServerCtx(conn) if (conn) return global.createServerCtx(conn)
}) })
const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory))
const [session] = createResource(
() => {
const ctx = dirSyncCtx()
if (!ctx || !props.sessionId) return
return [props.sessionId, ctx] as const
},
async ([sessionId, dirSyncCtx]) => {
await dirSyncCtx.session.sync(sessionId).catch(() => {})
return dirSyncCtx.session.get(sessionId)
},
{ initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined },
)
return ( return (
<div <div
@ -851,7 +856,7 @@ function TabNavItem(props: {
closeTab(event) closeTab(event)
}} }}
> >
<Show when={session.latest}> <Show when={props.session}>
{(session) => { {(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? [])) const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
@ -867,7 +872,7 @@ function TabNavItem(props: {
<span data-slot="project-avatar-slot"> <span data-slot="project-avatar-slot">
<ProjectTabAvatar <ProjectTabAvatar
project={project()} project={project()}
directory={props.directory} directory={session().directory}
sessionId={session().id} sessionId={session().id}
activeServer={props.activeServer} activeServer={props.activeServer}
/> />

View file

@ -2,12 +2,14 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { createScopedCache } from "@/utils/scoped-cache" import { createScopedCache } from "@/utils/scoped-cache"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import type { SelectedLineRange } from "@/context/file" import type { SelectedLineRange } from "@/context/file"
import { useSDK } from "./sdk"
export type LineComment = { export type LineComment = {
id: string id: string
@ -202,6 +204,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
gate: false, gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const sdk = useSDK()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const cache = createScopedCache( const cache = createScopedCache(
(key) => { (key) => {
@ -228,7 +231,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
return cache.get(key).value return cache.get(key).value
} }
const session = createMemo(() => load(params.dir!, params.id)) const session = createMemo(() => load(base64Encode(sdk().directory), params.id))
return { return {
ready: () => session().ready(), ready: () => session().ready(),

View file

@ -3,6 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
@ -65,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const scope = createMemo(() => sdk().directory) const scope = createMemo(() => sdk().directory)
const path = createPathHelpers(scope) const path = createPathHelpers(scope)
const tabs = layout.tabs(() => const tabs = layout.tabs(() =>
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)), SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
) )
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()

View file

@ -16,6 +16,7 @@ import { createPathHelpers } from "./file/path"
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope" import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
import { requireServerKey } from "@/utils/session-route"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@ -79,7 +80,7 @@ export type LayoutRoute =
| { type: "home" } | { type: "home" }
| { type: "draft"; draftID: string; server?: ServerConnection.Key } | { type: "draft"; draftID: string; server?: ServerConnection.Key }
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key } | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
| { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key } | { type: "session"; sessionId: string; server?: ServerConnection.Key }
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs { function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
const all = current?.all ?? [] const all = current?.all ?? []
@ -131,6 +132,14 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
return { type: "draft", draftID } return { type: "draft", draftID }
} }
if (parts[0] === "server" && parts[2] === "session" && parts[3]) {
return {
type: "session",
sessionId: parts[3],
server: requireServerKey(parts[1]),
}
}
const dirBase64 = parts[0] const dirBase64 = parts[0]
const dir = decode64(dirBase64) const dir = decode64(dirBase64)
if (!dir) return { type: "home" } if (!dir) return { type: "home" }
@ -138,7 +147,7 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
if (parts[1] !== "session") return { type: "home" } if (parts[1] !== "session") return { type: "home" }
const id = parts[2] const id = parts[2]
if (id) return { type: "session", dir, dirBase64, sessionId: id } if (id) return { type: "session", sessionId: id }
return { type: "dir-new-sesssion", dir, dirBase64 } return { type: "dir-new-sesssion", dir, dirBase64 }
} }
@ -154,6 +163,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const route = createMemo(() => { const route = createMemo(() => {
const value = currentRoute(location.pathname, location.search) const value = currentRoute(location.pathname, location.search)
if (value.type === "home") return value if (value.type === "home") return value
if (value.server) return value
return { ...value, server: server.key } return { ...value, server: server.key }
}) })
@ -572,7 +582,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
handoff: { handoff: {
tabs: createMemo(() => store.handoff?.tabs), tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) { setTabs(dir: string, id: string) {
setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() }) setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() })
}, },
clearTabs() { clearTabs() {
if (!store.handoff?.tabs) return if (!store.handoff?.tabs) return

View file

@ -1,5 +1,5 @@
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
@ -108,7 +108,7 @@ function buildNotificationIndex(list: Notification[]) {
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification", name: "Notification",
gate: false, gate: false,
init: () => { init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
const params = useParams() const params = useParams()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
@ -119,10 +119,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const empty: Notification[] = [] const empty: Notification[] = []
const currentDirectory = createMemo(() => { const currentDirectory = createMemo(() => {
return decode64(params.dir) return props.directory?.() ?? decode64(params.dir)
}) })
const currentSession = createMemo(() => params.id) const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]), Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),

View file

@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup } from "solid-js" import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
@ -47,13 +47,13 @@ function hasPermissionPromptRules(permission: unknown) {
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission", name: "Permission",
gate: false, gate: false,
init: () => { init: (props: { directory?: Accessor<string | undefined> }) => {
const params = useParams() const params = useParams()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
const permissionsEnabled = createMemo(() => { const permissionsEnabled = createMemo(() => {
const directory = decode64(params.dir) const directory = props.directory?.() ?? decode64(params.dir)
if (!directory) return false if (!directory) return false
const [store] = serverSync().child(directory) const [store] = serverSync().child(directory)
return hasPermissionPromptRules(store.config.permission) return hasPermissionPromptRules(store.config.permission)
@ -85,7 +85,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
// When config has permission: "allow", auto-enable directory-level auto-accept // When config has permission: "allow", auto-enable directory-level auto-accept
createEffect(() => { createEffect(() => {
if (!ready()) return if (!ready()) return
const directory = decode64(params.dir) const directory = props.directory?.() ?? decode64(params.dir)
if (!directory) return if (!directory) return
const [childStore] = serverSync().child(directory) const [childStore] = serverSync().child(directory)
const perm = childStore.config.permission const perm = childStore.config.permission

View file

@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { checksum } from "@opencode-ai/core/util/encode" import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useParams, useSearchParams } from "@solidjs/router" import { useParams, useSearchParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store" import { createStore, type SetStoreFunction } from "solid-js/store"
@ -7,6 +7,7 @@ import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { useSDK } from "./sdk"
interface PartBase { interface PartBase {
content: string content: string
@ -256,6 +257,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
gate: false, gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const sdk = useSDK()
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const cache = new Map<string, PromptCacheEntry>() const cache = new Map<string, PromptCacheEntry>()
@ -303,7 +305,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
const session = createMemo(() => const session = createMemo(() =>
load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }), load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
) )
const pick = (scope?: Scope) => (scope ? load(scope) : session()) const pick = (scope?: Scope) => (scope ? load(scope) : session())

View file

@ -1,6 +1,5 @@
import type { Session } from "@opencode-ai/sdk/v2/client" import type { Session } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist" import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
import { ServerConnection, useServer } from "./server" import { ServerConnection, useServer } from "./server"
@ -9,11 +8,11 @@ import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route"
export type SessionTab = { export type SessionTab = {
type: "session" type: "session"
server: ServerConnection.Key server: ServerConnection.Key
dirBase64: string
sessionId: string sessionId: string
} }
@ -34,16 +33,12 @@ type RecentTab = {
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}` export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
export const tabHref = (tab: Tab) => export const tabHref = (tab: Tab) =>
tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}` tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.sessionId)
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`) export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) { export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
const dirBase64 = base64Encode(session.directory) return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
return tabs.some(
(tab) =>
tab.type === "session" && tab.server === server && tab.dirBase64 === dirBase64 && tab.sessionId === session.id,
)
} }
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
@ -105,14 +100,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const navigateTab = (tab: Tab) => { const navigateTab = (tab: Tab) => {
const href = tabHref(tab) const href = tabHref(tab)
setRecentKey(tabKey(tab)) setRecentKey(tabKey(tab))
if (tab.server === server.key) { navigate(href)
navigate(href)
return
}
void startTransition(() => {
server.setActive(tab.server)
navigate(href)
})
} }
const actions = { const actions = {
@ -195,11 +183,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
removeSessions: (input: SessionTabsRemovedDetail) => { removeSessions: (input: SessionTabsRemovedDetail) => {
const removed = store const removed = store
.filter( .filter(
(tab) => (tab) => tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId),
tab.type === "session" &&
tab.server === server.key &&
atob(tab.dirBase64) === input.directory &&
input.sessionIDs.includes(tab.sessionId),
) )
.map(tabKey) .map(tabKey)
void startTransition(() => { void startTransition(() => {
@ -211,7 +195,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
? tabHref({ ? tabHref({
type: "session", type: "session",
server: server.key, server: server.key,
dirBase64: params.dir,
sessionId: params.id, sessionId: params.id,
}) })
: undefined : undefined
@ -224,14 +207,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const removedCurrent = const removedCurrent =
currentTab?.type === "session" && currentTab?.type === "session" &&
currentTab.server === server.key && currentTab.server === server.key &&
atob(currentTab.dirBase64) === input.directory &&
sessionIDs.has(currentTab.sessionId) sessionIDs.has(currentTab.sessionId)
for (let i = tabs.length - 1; i >= 0; i--) { for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i] const tab = tabs[i]
if (!tab || tab.type !== "session") continue if (!tab || tab.type !== "session") continue
if (tab.server !== server.key) continue if (tab.server !== server.key) continue
if (atob(tab.dirBase64) !== input.directory) continue
if (!sessionIDs.has(tab.sessionId)) continue if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1) tabs.splice(i, 1)
} }

View file

@ -4,7 +4,8 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { useSDK, type DirectorySDK } from "./sdk" import { useSDK, type DirectorySDK } from "./sdk"
import type { Platform } from "./platform" import type { Platform } from "./platform"
import { useServer } from "./server" import { useServerSDK } from "./server-sdk"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { defaultTitle, titleNumber } from "./terminal-title" import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist" import { Persist, persisted, removePersisted } from "@/utils/persist"
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope" import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
@ -374,10 +375,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
gate: false, gate: false,
init: () => { init: () => {
const sdk = useSDK() const sdk = useSDK()
const server = useServer() const serverSDK = useServerSDK()
const params = useParams() const params = useParams()
const cache = new Map<string, TerminalCacheEntry>() const cache = new Map<string, TerminalCacheEntry>()
const scope = server.scope() const scope = () => serverSDK().scope
const directory = createMemo(() => base64Encode(sdk().directory))
caches.add(cache) caches.add(cache)
onCleanup(() => caches.delete(cache)) onCleanup(() => caches.delete(cache))
@ -421,11 +423,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value return entry.value
} }
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope)) const workspace = createMemo(() => loadWorkspace(directory(), params.id, scope()))
createEffect( createEffect(
on( on(
() => ({ dir: params.dir, id: params.id, scope }), () => ({ dir: directory(), id: params.id, scope: scope() }),
(next, prev) => { (next, prev) => {
if (!prev?.dir) return if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return

View file

@ -2,26 +2,40 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" import { type Accessor, createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local" import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { Schema } from "effect" import { Schema } from "effect"
import type { ServerConnection } from "@/context/server"
import { sessionHref } from "@/utils/session-route"
export function DirectoryDataProvider(props: ParentProps<{ directory: string; draftID?: string }>) { export function DirectoryDataProvider(
props: ParentProps<{
directory: string | Accessor<string>
draftID?: string
server?: Accessor<ServerConnection.Key | undefined>
}>,
) {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const params = useParams() const params = useParams()
const sync = useSync() const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory)) const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
const slug = createMemo(() => base64Encode(directory()))
const href = (sessionID: string) => {
const server = props.server?.()
if (server) return sessionHref(server, sessionID)
return `/${slug()}/session/${sessionID}`
}
createEffect(() => { createEffect(() => {
// A draft lives at /new-session?draftId=… and has no directory segment to normalize. // A draft lives at /new-session?draftId=… and has no directory segment to normalize.
if (props.draftID) return if (props.draftID || props.server?.()) return
const next = sync().data.path.directory const next = sync().data.path.directory
if (!next || next === props.directory) return if (!next || next === directory()) return
const path = location.pathname.slice(slug().length + 1) const path = location.pathname.slice(slug().length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
}) })
@ -37,9 +51,9 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr
return ( return (
<DataProvider <DataProvider
data={sync().data} data={sync().data}
directory={props.directory} directory={directory()}
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)} onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`} onSessionHref={href}
> >
<LocalProvider>{props.children}</LocalProvider> <LocalProvider>{props.children}</LocalProvider>
</DataProvider> </DataProvider>

View file

@ -43,7 +43,6 @@ import { sessionTitle } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useSettings } from "@/context/settings"
import { ServerRowMenu } from "@/components/server/server-row-menu" import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health" import { type ServerHealth } from "@/utils/server-health"
@ -113,16 +112,7 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}` return `${pathKey(record.session.directory)}:${record.session.id}`
} }
export default function Home() { export function NewHome() {
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
<HomeDesign />
</Show>
)
}
function HomeDesign() {
const sync = useServerSync() const sync = useServerSync()
const layout = useLayout() const layout = useLayout()
const platform = usePlatform() const platform = usePlatform()
@ -313,7 +303,7 @@ function HomeDesign() {
const ctx = global.createServerCtx(conn) const ctx = global.createServerCtx(conn)
ctx.projects.open(directory) ctx.projects.open(directory)
ctx.projects.touch(directory) ctx.projects.touch(directory)
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`) navigateOnServer(conn, `/server/${base64Encode(ServerConnection.key(conn))}/session/${session.id}`)
} }
function chooseProject(conn: ServerConnection.Any) { function chooseProject(conn: ServerConnection.Any) {
@ -416,7 +406,7 @@ function HomeDesign() {
record={record} record={record}
server={state.selection.server} server={state.selection.server}
activeServer={state.selection.server === server.key} activeServer={state.selection.server === server.key}
openSession={openSession} onClick={() => openSession(record.session)}
/> />
)} )}
</For> </For>
@ -1046,7 +1036,7 @@ function HomeSessionRow(props: {
record: HomeSessionRecord record: HomeSessionRecord
server: ServerConnection.Key server: ServerConnection.Key
activeServer: boolean activeServer: boolean
openSession: (session: Session) => void onClick: () => void
}) { }) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
@ -1055,7 +1045,7 @@ function HomeSessionRow(props: {
type="button" type="button"
data-component="home-session-row" data-component="home-session-row"
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`} class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
onClick={() => props.openSession(props.record.session)} onClick={props.onClick}
> >
<HomeSessionLeading <HomeSessionLeading
project={props.record.project} project={props.record.project}
@ -1115,7 +1105,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
].filter((group) => group.sessions.length > 0) ].filter((group) => group.sessions.length > 0)
} }
function LegacyHome() { export function LegacyHome() {
const sync = useServerSync() const sync = useServerSync()
const platform = usePlatform() const platform = usePlatform()
const pickDirectory = useDirectoryPicker() const pickDirectory = useDirectoryPicker()

View file

@ -0,0 +1,38 @@
import { createEffect, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast"
export default function NewLayout(props: ParentProps) {
const platform = usePlatform()
const navigate = useNavigate()
setNavigate(navigate)
createEffect(() => setV2Toast(true))
const update: TitlebarUpdate = {
version: () => {
const state = platform.updater?.state()
if (state?.status !== "ready") return
return state.version
},
installing: () => platform.updater?.state().status === "installing",
install: () => void platform.updater?.install(),
}
return (
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Titlebar update={update} />
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
{props.children}
</main>
{import.meta.env.DEV && <DebugBar />}
<HelpButton />
<ToastRegion v2 />
</div>
)
}

View file

@ -13,7 +13,7 @@ import {
type Accessor, type Accessor,
} from "solid-js" } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout" import { useLayout, LocalProject } from "@/context/layout"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@ -92,7 +92,7 @@ import {
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell" import { SidebarContent } from "./layout/sidebar-shell"
export default function Layout(props: ParentProps) { export default function LegacyLayout(props: ParentProps) {
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const [store, setStore, , ready] = persisted( const [store, setStore, , ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]), Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
@ -131,10 +131,8 @@ export default function Layout(props: ParentProps) {
const command = useCommand() const command = useCommand()
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const newDesign = createMemo(() => settings.general.newLayoutDesigns()) createEffect(() => setV2Toast(false))
createEffect(() => setV2Toast(newDesign()))
const initialDirectory = decode64(params.dir) const initialDirectory = decode64(params.dir)
const location = useLocation()
const route = createMemo(() => { const route = createMemo(() => {
const slug = params.dir const slug = params.dir
if (!slug) return { slug, dir: "" } if (!slug) return { slug, dir: "" }
@ -158,7 +156,7 @@ export default function Layout(props: ParentProps) {
const currentDir = createMemo(() => route().dir) const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({ const [state, setState] = createStore({
autoselect: !initialDirectory && !newDesign(), autoselect: !initialDirectory,
busyWorkspaces: {} as Record<string, boolean>, busyWorkspaces: {} as Record<string, boolean>,
hoverProject: undefined as string | undefined, hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined, scrollSessionKey: undefined as string | undefined,
@ -996,7 +994,7 @@ export default function Layout(props: ParentProps) {
id: "sidebar.toggle", id: "sidebar.toggle",
title: language.t("command.sidebar.toggle"), title: language.t("command.sidebar.toggle"),
category: language.t("command.category.view"), category: language.t("command.category.view"),
keybind: newDesign() ? undefined : "mod+b", keybind: "mod+b",
onSelect: () => layout.sidebar.toggle(), onSelect: () => layout.sidebar.toggle(),
}, },
{ {
@ -1134,20 +1132,19 @@ export default function Layout(props: ParentProps) {
}, },
] ]
if (!newDesign()) Array.from({ length: 9 }, (_, i) => {
Array.from({ length: 9 }, (_, i) => { const index = i
const index = i const number = index + 1
const number = index + 1 commands.push({
commands.push({ id: `project.${number}`,
id: `project.${number}`, category: language.t("command.category.project"),
category: language.t("command.category.project"), title: `Open Project {number}`,
title: `Open Project {number}`, keybind: `mod+${number}`,
keybind: `mod+${number}`, disabled: layout.projects.list().length <= index,
disabled: layout.projects.list().length <= index, hidden: true,
hidden: true, onSelect: () => navigateToProjectIndex(index),
onSelect: () => navigateToProjectIndex(index),
})
}) })
})
for (const [id] of availableThemeEntries()) { for (const [id] of availableThemeEntries()) {
commands.push({ commands.push({
@ -1812,7 +1809,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => { createEffect(() => {
document.documentElement.style.setProperty( document.documentElement.style.setProperty(
"--dialog-left-margin", "--dialog-left-margin",
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`, `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
) )
}) })
@ -2355,176 +2352,158 @@ export default function Layout(props: ParentProps) {
) )
return ( return (
<Show <div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
when={!newDesign()} {autoselecting() ?? ""}
fallback={ <Titlebar update={titlebarUpdate} />
<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"> <Show when={updateVersion() !== undefined}>
{autoselecting() ?? ""} <UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
<Titlebar update={titlebarUpdate} /> </Show>
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict"> <div class="flex-1 min-h-0 min-w-0 flex">
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}> <div class="flex-1 min-h-0 relative">
{props.children} <div class="size-full relative overflow-x-hidden">
</Show> <nav
</main> aria-label={language.t("sidebar.nav.projectsAndSessions")}
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />} data-component="sidebar-nav-desktop"
<HelpButton /> classList={{
<ToastRegion v2={newDesign()} /> "hidden xl:block": true,
</div> "absolute inset-y-0 left-0": true,
} "z-10": true,
> }}
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"> style={{ width: `${side()}px` }}
{autoselecting() ?? ""} ref={(el) => {
<Titlebar update={titlebarUpdate} /> setState("nav", el)
<Show when={updateVersion() !== undefined}> }}
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} /> onMouseEnter={() => {
</Show> disarm()
<div class="flex-1 min-h-0 min-w-0 flex"> }}
<div class="flex-1 min-h-0 relative"> onMouseLeave={() => {
<div class="size-full relative overflow-x-hidden"> aim.reset()
<nav if (!sidebarHovering()) return
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
arm() arm()
}} }}
> >
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div> <div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
</nav> </nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<Show when={layout.sidebar.opened()}>
<div <div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base" class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: "calc(4rem + 12px)" }} style={{ left: `${side()}px` }}
/> onPointerDown={() => setState("sizing", true)}
>
<div class="xl:hidden"> <ResizeHandle
<div direction="horizontal"
classList={{ size={layout.sidebar.width()}
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true, min={244}
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(), max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(), onResize={(w) => {
}} setState("sizing", true)
onClick={(e) => { if (sizet !== undefined) clearTimeout(sizet)
if (e.target === e.currentTarget) layout.mobileSidebar.hide() sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}} }}
/> />
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
</nav>
</div> </div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div <div
classList={{ classList={{
"absolute inset-0": true, "fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true, "opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"z-20": true, "opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}} }}
style={{ onClick={(e) => {
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem", if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}} }}
> />
<main <nav
classList={{ aria-label={language.t("sidebar.nav.projectsAndSessions")}
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true, data-component="sidebar-nav-mobile"
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
</div>
<div
classList={{ classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true, "@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(), "translate-x-0": layout.mobileSidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(), "-translate-x-full": !layout.mobileSidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}} }}
onMouseMove={disarm} onClick={(e) => e.stopPropagation()}
onMouseEnter={() => { >
disarm() {sidebarContent(true)}
aim.reset() </nav>
}} </div>
onPointerDown={disarm}
onMouseLeave={() => { <div
arm() classList={{
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}}
>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}} }}
> >
<Show when={peekProject()}> <Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
<SidebarPanel project={peekProject} merged={false} /> {props.children}
</Show> </Show>
</div> </main>
</div>
<div <div
classList={{ classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true, "hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(), "opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(), "opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true, "transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(), "duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(), "duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}} }}
style={{ left: `calc(4rem + ${panel()}px)` }} onMouseMove={disarm}
> onMouseEnter={() => {
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} /> disarm()
</div> aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
</Show>
</div>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div> </div>
</div> </div>
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
</div> </div>
<HelpButton /> {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
<ToastRegion v2={newDesign()} />
</div> </div>
</Show> <HelpButton />
<ToastRegion v2={false} />
</div>
) )
} }

View file

@ -28,7 +28,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { checksum } from "@opencode-ai/core/util/encode" import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useSearchParams } from "@solidjs/router" import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session" import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
@ -56,7 +56,6 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
import { createTimelineModel } from "@/pages/session/timeline/model" import { createTimelineModel } from "@/pages/session/timeline/model"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { useServer } from "@/context/server"
import { syncSessionModel } from "@/pages/session/session-model-helpers" import { syncSessionModel } from "@/pages/session/session-model-helpers"
import { SessionSidePanel } from "@/pages/session/session-side-panel" import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel" import { TerminalPanel } from "@/pages/session/terminal-panel"
@ -92,7 +91,6 @@ export default function Page() {
const prompt = usePrompt() const prompt = usePrompt()
const comments = useComments() const comments = useComments()
const terminal = useTerminal() const terminal = useTerminal()
const server = useServer()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation() const location = useLocation()
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
@ -137,11 +135,11 @@ export default function Page() {
layout.handoff.clearTabs() layout.handoff.clearTabs()
return return
} }
if (pending.scope !== server.scope()) return if (pending.scope !== serverSDK().scope) return
if (pending.id !== id) return if (pending.id !== id) return
layout.handoff.clearTabs() layout.handoff.clearTabs()
if (pending.dir !== (params.dir ?? "")) return if (pending.dir !== base64Encode(sdk().directory)) return
const from = workspaceTabs().tabs() const from = workspaceTabs().tabs()
if (from.all.length === 0 && !from.active) return if (from.all.length === 0 && !from.active) return
@ -247,7 +245,7 @@ export default function Page() {
createEffect( createEffect(
on( on(
() => ({ dir: params.dir, id: params.id }), () => ({ dir: sdk().directory, id: params.id }),
(next, prev) => { (next, prev) => {
if (!prev) return if (!prev) return
if (next.dir === prev.dir && next.id === prev.id) return if (next.dir === prev.dir && next.id === prev.id) return
@ -575,7 +573,7 @@ export default function Page() {
createEffect( createEffect(
on( on(
() => params.dir, () => sdk().directory,
(dir) => { (dir) => {
if (!dir) return if (!dir) return
setStore("newSessionWorktree", "main") setStore("newSessionWorktree", "main")

View file

@ -29,6 +29,7 @@ import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useDirectoryPicker } from "@/components/directory-picker" import { useDirectoryPicker } from "@/components/directory-picker"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
export function SessionComposerRegion(props: { export function SessionComposerRegion(props: {
state: SessionComposerState state: SessionComposerState
@ -200,7 +201,11 @@ export function SessionComposerRegion(props: {
const openParent = () => { const openParent = () => {
const id = parentID() const id = parentID()
if (!id) return if (!id) return
navigate(`/${route.params.dir}/session/${id}`) navigate(
route.params.serverKey
? sessionHref(requireServerKey(route.params.serverKey), id)
: legacySessionHref(sdk().directory, id),
)
} }
createEffect(() => { createEffect(() => {

View file

@ -1,15 +1,19 @@
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useServer } from "@/context/server"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { base64Encode } from "@opencode-ai/core/util/encode"
export const useSessionKey = () => { export const useSessionKey = () => {
const params = useParams() const params = useParams()
const server = useServer() const sdk = useSDK()
const scope = createMemo(() => server.scope()) const serverSDK = useServerSDK()
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir))) const scope = createMemo(() => serverSDK().scope)
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir, params.id))) const directory = createMemo(() => base64Encode(sdk().directory))
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory())))
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory(), params.id)))
return { params, sessionKey, workspaceKey } return { params, sessionKey, workspaceKey }
} }

View file

@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTerminal } from "@/context/terminal" import { useTerminal } from "@/context/terminal"
import { useSDK } from "@/context/sdk"
import { terminalTabLabel } from "@/pages/session/terminal-label" import { terminalTabLabel } from "@/pages/session/terminal-label"
import { createSizing, focusTerminalById } from "@/pages/session/helpers" import { createSizing, focusTerminalById } from "@/pages/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
@ -25,10 +26,11 @@ export function TerminalPanel() {
const delays = [120, 240] const delays = [120, 240]
const layout = useLayout() const layout = useLayout()
const terminal = useTerminal() const terminal = useTerminal()
const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const command = useCommand() const command = useCommand()
const settings = useSettings() const settings = useSettings()
const { params, workspaceKey, view } = useSessionLayout() const { workspaceKey, view } = useSessionLayout()
const opened = createMemo(() => view().terminal.opened()) const opened = createMemo(() => view().terminal.opened())
const size = createSizing() const size = createSizing()
@ -122,7 +124,7 @@ export function TerminalPanel() {
}) })
createEffect(() => { createEffect(() => {
const dir = params.dir const dir = sdk().directory
if (!dir) return if (!dir) return
if (!terminal.ready()) return if (!terminal.ready()) return
language.locale() language.locale()
@ -140,7 +142,7 @@ export function TerminalPanel() {
}) })
const handoff = createMemo(() => { const handoff = createMemo(() => {
const dir = params.dir const dir = sdk().directory
if (!dir) return [] if (!dir) return []
return getTerminalHandoff(workspaceKey()) ?? [] return getTerminalHandoff(workspaceKey()) ?? []
}) })

View file

@ -62,6 +62,8 @@ import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
@ -260,6 +262,7 @@ export function MessageTimeline(props: {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const settings = useSettings() const settings = useSettings()
const tabs = useTabs()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const { params, sessionKey } = useSessionKey() const { params, sessionKey } = useSessionKey()
@ -757,12 +760,18 @@ export function MessageTimeline(props: {
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return if (params.id !== sessionID) return
const href = (id: string) =>
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
if (parentID) { if (parentID) {
navigate(`/${params.dir}/session/${parentID}`) navigate(href(parentID))
return return
} }
if (nextSessionID) { if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`) navigate(href(nextSessionID))
return
}
if (params.serverKey) {
tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
return return
} }
navigate(`/${params.dir}/session`) navigate(`/${params.dir}/session`)
@ -864,7 +873,9 @@ export function MessageTimeline(props: {
const navigateParent = () => { const navigateParent = () => {
const id = parentID() const id = parentID()
if (!id) return if (!id) return
navigate(`/${params.dir}/session/${id}`) navigate(
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id),
)
} }
function DialogDeleteSession(props: { sessionID: string }) { function DialogDeleteSession(props: { sessionID: string }) {

View file

@ -18,6 +18,8 @@ import { createSessionTabs } from "@/pages/session/helpers"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2" import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { useTabs } from "@/context/tabs"
import { requireServerKey } from "@/utils/session-route"
export type SessionCommandContext = { export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void navigateMessageByOffset: (offset: number) => void
@ -45,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const settings = useSettings() const settings = useSettings()
const sync = useSync() const sync = useSync()
const terminal = useTerminal() const terminal = useTerminal()
const sessionTabs = useTabs()
const layout = useLayout() const layout = useLayout()
const navigate = useNavigate() const navigate = useNavigate()
const { params, tabs, view } = useSessionLayout() const { params, tabs, view } = useSessionLayout()
@ -381,7 +384,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.new"), title: language.t("command.session.new"),
keybind: "mod+shift+s", keybind: "mod+shift+s",
slash: "new", slash: "new",
onSelect: () => navigate(`/${params.dir}/session`), onSelect: () => {
if (params.serverKey) {
sessionTabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
return
}
navigate(`/${params.dir}/session`)
},
}), }),
sessionCommand({ sessionCommand({
id: "session.undo", id: "session.undo",

View file

@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route"
describe("session routes", () => {
test("builds and decodes a server-keyed session route", () => {
const server = ServerConnection.Key.make("https://example.com:4096")
const href = sessionHref(server, "session-1")
expect(href).toBe("/server/aHR0cHM6Ly9leGFtcGxlLmNvbTo0MDk2/session/session-1")
expect(requireServerKey(href.split("/")[2])).toBe(server)
})
test("rejects malformed server keys", () => {
expect(() => requireServerKey("not-base64")).toThrow("Invalid server route")
})
test("builds the legacy directory-keyed route", () => {
expect(legacySessionHref("/Users/example/project", "session-1")).toBe(
"/L1VzZXJzL2V4YW1wbGUvcHJvamVjdA/session/session-1",
)
})
test("resolves the root session", async () => {
const sessions: Record<string, { id: string; parentID?: string }> = {
child: { id: "child", parentID: "parent" },
parent: { id: "parent", parentID: "root" },
root: { id: "root" },
}
expect(
await rootSession(sessions.child, async (id) => {
const session = sessions[id]
if (!session) throw new Error(`Missing session: ${id}`)
return session
}),
).toBe(sessions.root)
})
})

View file

@ -0,0 +1,25 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { ServerConnection } from "@/context/server"
import { decode64 } from "@/utils/base64"
export function sessionHref(server: ServerConnection.Key, sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
export function legacySessionHref(directory: string, sessionID: string) {
return `/${base64Encode(directory)}/session/${sessionID}`
}
export function requireServerKey(segment: string | undefined) {
const key = decode64(segment)
if (!key || base64Encode(key) !== segment) throw new Error("Invalid server route")
return ServerConnection.Key.make(key)
}
type SessionParent = { id: string; parentID?: string }
export async function rootSession(session: SessionParent, get: (sessionID: string) => Promise<SessionParent>) {
let current = session
while (current.parentID) current = await get(current.parentID)
return current
}

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli", "name": "@opencode-ai/cli",
"version": "1.17.8", "version": "1.17.9",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"bin": { "bin": {

View file

@ -27,9 +27,11 @@ export default Runtime.handler(
function listen(hostname: string, port: Option.Option<number>, password: string) { function listen(hostname: string, port: Option.Option<number>, password: string) {
if (Option.isSome(port)) return bind(hostname, port.value, password) if (Option.isSome(port)) return bind(hostname, port.value, password)
// Preserve the familiar default when available, but let the OS choose a free const next = (port: number): ReturnType<typeof bind> =>
// port when another local server already owns 4096. bind(hostname, port, password).pipe(
return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password))) Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
)
return next(4096)
} }
function bind(hostname: string, port: number, password: string) { function bind(hostname: string, port: number, password: string) {

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.17.8", "version": "1.17.9",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -157,9 +157,6 @@ export async function handler(
logger.metric({ logger.metric({
provider: providerInfo.id, provider: providerInfo.id,
"provider.model": providerInfo.model, "provider.model": providerInfo.model,
...(providerBudgetUsage?.[providerInfo.id]
? { "provider.budget_usage": providerBudgetUsage?.[providerInfo.id] }
: {}),
}) })
const startTimestamp = Date.now() const startTimestamp = Date.now()
@ -199,6 +196,13 @@ export async function handler(
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => { Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
headers.set(k, headers.get(v)!) headers.set(k, headers.get(v)!)
}) })
Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => {
if (v === "$ip") return headers.set(k, ip)
if (v === "$session") return headers.set(k, sessionId)
if (v === "$model") return headers.set(k, model)
if (v === "$request") return headers.set(k, requestId)
headers.set(k, v)
})
headers.delete("host") headers.delete("host")
headers.delete("content-length") headers.delete("content-length")
headers.delete("x-opencode-request") headers.delete("x-opencode-request")

View file

@ -1,5 +1,6 @@
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js" import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { buildRateLimitKey, getRedis } from "./redis" import { buildRateLimitKey, getRedis } from "./redis"
import { logger } from "./logger"
export function createProviderBudgetTracker( export function createProviderBudgetTracker(
providers: { providers: {
@ -22,13 +23,15 @@ export function createProviderBudgetTracker(
const keys = Object.fromEntries( const keys = Object.fromEntries(
tracked.map((provider) => [provider.id, buildRateLimitKey("provider-budget", provider.id, interval)]), tracked.map((provider) => [provider.id, buildRateLimitKey("provider-budget", provider.id, interval)]),
) )
let budgetUsage: Record<string, number> = {}
return { return {
check: async () => { check: async () => {
const ids = tracked.filter((provider) => provider.budgetMode === "fill").map((provider) => provider.id) const ids = tracked.map((provider) => provider.id)
if (ids.length === 0) return {} if (ids.length === 0) return {}
const values = await redis.mget<(string | number | null)[]>(ids.map((id) => keys[id])) const values = await redis.mget<(string | number | null)[]>(ids.map((id) => keys[id]))
return Object.fromEntries(ids.map((id, index) => [id, Number(values[index] ?? 0)])) budgetUsage = Object.fromEntries(ids.map((id, index) => [id, Number(values[index] ?? 0)]))
return budgetUsage
}, },
track: async (provider: string, costInCent: number) => { track: async (provider: string, costInCent: number) => {
const config = tracked.find((item) => item.id === provider) const config = tracked.find((item) => item.id === provider)
@ -40,6 +43,10 @@ export function createProviderBudgetTracker(
pipeline.incrby(keys[provider], cost) pipeline.incrby(keys[provider], cost)
pipeline.expire(keys[provider], 120) pipeline.expire(keys[provider], 120)
await pipeline.exec() await pipeline.exec()
logger.metric({
"provider.budget_usage": budgetUsage[provider] + cost,
"model.budget_usage": cost,
})
}, },
} }
} }

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.17.8", "version": "1.17.9",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View file

@ -53,6 +53,7 @@ export namespace ZenData {
apiKey: z.union([z.string(), z.record(z.string(), z.string())]), apiKey: z.union([z.string(), z.record(z.string(), z.string())]),
format: FormatSchema.optional(), format: FormatSchema.optional(),
headerMappings: z.record(z.string(), z.string()).optional(), headerMappings: z.record(z.string(), z.string()).optional(),
headerModifier: z.record(z.string(), z.any()).optional(),
payloadModifier: z.record(z.string(), z.any()).optional(), payloadModifier: z.record(z.string(), z.any()).optional(),
payloadMappings: z.record(z.string(), z.string()).optional(), payloadMappings: z.record(z.string(), z.string()).optional(),
adjustCacheUsage: z.boolean().optional(), adjustCacheUsage: z.boolean().optional(),

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.17.8", "version": "1.17.9",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.17.8", "version": "1.17.9",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-support", "name": "@opencode-ai/console-support",
"version": "1.17.8", "version": "1.17.9",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -0,0 +1,2 @@
[test]
preload = ["./test/preload.ts"]

View file

@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.17.8", "version": "1.17.9",
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@ -91,6 +91,7 @@
"@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opentelemetry/api": "1.9.0", "@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/exporter-trace-otlp-http": "0.214.0",

View file

@ -1,8 +1,8 @@
{ {
"version": "7", "version": "7",
"dialect": "sqlite", "dialect": "sqlite",
"id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db", "id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
"prevIds": ["abd2f920-b822-49af-b8a7-2e48367d424f"], "prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
"ddl": [ "ddl": [
{ {
"name": "workspace", "name": "workspace",
@ -900,16 +900,6 @@
"entityType": "columns", "entityType": "columns",
"table": "session_context_epoch" "table": "session_context_epoch"
}, },
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'build'",
"generated": null,
"name": "agent",
"entityType": "columns",
"table": "session_context_epoch"
},
{ {
"type": "text", "type": "text",
"notNull": true, "notNull": true,
@ -930,26 +920,6 @@
"entityType": "columns", "entityType": "columns",
"table": "session_context_epoch" "table": "session_context_epoch"
}, },
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "replacement_seq",
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "revision",
"entityType": "columns",
"table": "session_context_epoch"
},
{ {
"type": "text", "type": "text",
"notNull": false, "notNull": false,

View file

@ -45,15 +45,17 @@ async function generate() {
if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`) if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`)
await Bun.write( await Bun.write(
target, target,
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()), await formatTypescript(
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
),
) )
await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot) await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot)
} }
await fs.mkdir(full) await fs.mkdir(full)
await drizzle(temporary, full, "schema") await drizzle(temporary, full, "schema")
await Bun.write(schema, renderSchema(await generatedSql(full))) await Bun.write(schema, await formatTypescript(renderSchema(await generatedSql(full))))
await Bun.write(registry, renderRegistry(await typescriptMigrations())) await Bun.write(registry, await formatTypescript(renderRegistry(await typescriptMigrations())))
} finally { } finally {
await fs.rm(temporary, { recursive: true, force: true }) await fs.rm(temporary, { recursive: true, force: true })
} }
@ -76,12 +78,12 @@ async function check() {
await fs.mkdir(full) await fs.mkdir(full)
await drizzle(temporary, full, "schema") await drizzle(temporary, full, "schema")
if ((await Bun.file(schema).text()) !== renderSchema(await generatedSql(full))) { if ((await Bun.file(schema).text()) !== (await formatTypescript(renderSchema(await generatedSql(full))))) {
throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.") throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.")
} }
const migrations = await typescriptMigrations() const migrations = await typescriptMigrations()
if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) { if ((await Bun.file(registry).text()) !== (await formatTypescript(renderRegistry(migrations)))) {
throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.") throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
} }
} finally { } finally {
@ -170,6 +172,18 @@ function escapeTemplate(line: string) {
return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${") return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
} }
async function formatTypescript(input: string) {
const prettier = await import("prettier")
const typescript = await import("prettier/plugins/typescript")
const estree = await import("prettier/plugins/estree")
return prettier.format(input, {
parser: "typescript",
plugins: [typescript.default, estree.default],
semi: false,
printWidth: 120,
})
}
function renderRegistry(names: string[]) { function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration" return `import type { DatabaseMigration } from "./migration"

View file

@ -35,19 +35,19 @@ export class Org extends Schema.Class<Org>("Org")({
export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", { export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
message: Schema.String, message: Schema.String,
cause: Schema.optional(Schema.Defect), cause: Schema.optional(Schema.Defect()),
}) {} }) {}
export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", { export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
message: Schema.String, message: Schema.String,
cause: Schema.optional(Schema.Defect), cause: Schema.optional(Schema.Defect()),
}) {} }) {}
export class AccountTransportError extends Schema.TaggedErrorClass<AccountTransportError>()("AccountTransportError", { export class AccountTransportError extends Schema.TaggedErrorClass<AccountTransportError>()("AccountTransportError", {
method: Schema.String, method: Schema.String,
url: Schema.String, url: Schema.String,
description: Schema.optional(Schema.String), description: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect), cause: Schema.optional(Schema.Defect()),
}) { }) {
static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError { static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError {
return new AccountTransportError({ return new AccountTransportError({
@ -94,7 +94,7 @@ export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired"
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {} export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
export class PollError extends Schema.TaggedClass<PollError>()("PollError", { export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
cause: Schema.Defect, cause: Schema.Defect(),
}) {} }) {}
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError]) export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])

View file

@ -1,7 +1,6 @@
export * as AgentV2 from "./agent" export * as AgentV2 from "./agent"
import { Array, Context, Effect, Layer, Schema, Scope } from "effect" import { Array, Context, Effect, Layer, Schema, Scope, Types } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { PermissionSchema } from "./permission/schema" import { PermissionSchema } from "./permission/schema"
import { ProviderV2 } from "./provider" import { ProviderV2 } from "./provider"
@ -49,21 +48,19 @@ export interface Selection {
} }
type Data = { type Data = {
agents: Map<ID, Info> agents: Map<ID, Types.DeepMutable<Info>>
default?: ID default?: ID
} }
export type Editor = { export type Draft = {
list: () => readonly Info[] list: () => readonly Info[]
get: (id: ID) => Info | undefined get: (id: ID) => Info | undefined
default: (id: ID | undefined) => void default: (id: ID | undefined) => void
update: (id: ID, fn: (agent: Draft<Info>) => void) => void update: (id: ID, fn: (agent: Types.DeepMutable<Info>) => void) => void
remove: (id: ID) => void remove: (id: ID) => void
} }
export interface Interface { export interface Interface extends State.Transformable<Draft> {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
readonly get: (id: ID) => Effect.Effect<Info | undefined> readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly default: () => Effect.Effect<Info | undefined> readonly default: () => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined> readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
@ -73,21 +70,19 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
enableMapSet()
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const state = State.create<Data, Editor>({ const state = State.create<Data, Draft>({
initial: () => ({ agents: new Map() }), initial: () => ({ agents: new Map() }),
editor: (draft) => ({ draft: (draft) => ({
list: () => Array.fromIterable(draft.agents.values()) as Info[], list: () => Array.fromIterable(draft.agents.values()) as Info[],
get: (id) => draft.agents.get(id), get: (id) => draft.agents.get(id),
default: (id) => { default: (id) => {
draft.default = id draft.default = id
}, },
update: (id, fn) => { update: (id, fn) => {
const current = draft.agents.get(id) ?? castDraft(Info.empty(id)) const current = draft.agents.get(id) ?? (Info.empty(id) as Types.DeepMutable<Info>)
if (!draft.agents.has(id)) draft.agents.set(id, current) if (!draft.agents.has(id)) draft.agents.set(id, current)
fn(current) fn(current)
current.id = id current.id = id
@ -113,7 +108,7 @@ export const layer = Layer.effect(
return Service.of({ return Service.of({
transform: state.transform, transform: state.transform,
update: state.update, reload: state.reload,
get: Effect.fn("AgentV2.get")(function* (id) { get: Effect.fn("AgentV2.get")(function* (id) {
return state.get().agents.get(id) return state.get().agents.get(id)
}), }),

View file

@ -1,14 +1,27 @@
export * as AISDK from "./aisdk" export * as AISDK from "./aisdk"
import type { LanguageModelV3 } from "@ai-sdk/provider" import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Cause, Context, Effect, Layer, Schema } from "effect" import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { EventV2 } from "./event"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider" import { ProviderV2 } from "./provider"
import { State } from "./state"
type SDK = any type SDK = any
export interface SDKEvent {
readonly model: ModelV2.Info
readonly package: string
readonly options: Record<string, any>
sdk?: SDK
}
export interface LanguageEvent {
readonly model: ModelV2.Info
readonly sdk: SDK
readonly options: Record<string, any>
language?: LanguageModelV3
}
function wrapSSE(res: Response, ms: number, ctl: AbortController) { function wrapSSE(res: Response, ms: number, ctl: AbortController) {
if (typeof ms !== "number" || ms <= 0) return res if (typeof ms !== "number" || ms <= 0) return res
if (!res.body) return res if (!res.body) return res
@ -109,7 +122,7 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
export class InitError extends Schema.TaggedErrorClass<InitError>()("AISDK.InitError", { export class InitError extends Schema.TaggedErrorClass<InitError>()("AISDK.InitError", {
providerID: ProviderV2.ID, providerID: ProviderV2.ID,
cause: Schema.Defect, cause: Schema.Defect(),
}) {} }) {}
function initError(providerID: ProviderV2.ID) { function initError(providerID: ProviderV2.ID) {
@ -117,19 +130,70 @@ function initError(providerID: ProviderV2.ID) {
} }
export interface Interface { export interface Interface {
readonly hook: {
readonly sdk: (
callback: (event: SDKEvent) => Effect.Effect<void> | void,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly language: (
callback: (event: LanguageEvent) => Effect.Effect<void> | void,
) => Effect.Effect<State.Registration, never, Scope.Scope>
}
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError> readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {}
export const layer = Layer.effect( export const locationLayer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service let sdkHooks: ((event: SDKEvent) => Effect.Effect<void> | void)[] = []
let languageHooks: ((event: LanguageEvent) => Effect.Effect<void> | void)[] = []
const languages = new Map<string, LanguageModelV3>() const languages = new Map<string, LanguageModelV3>()
const sdks = new Map<string, SDK>() const sdks = new Map<string, SDK>()
return Service.of({ const register = <Event>(
hooks: () => ((event: Event) => Effect.Effect<void> | void)[],
update: (hooks: ((event: Event) => Effect.Effect<void> | void)[]) => void,
) =>
Effect.fn("AISDK.hook")(function* (callback: (event: Event) => Effect.Effect<void> | void) {
const scope = yield* Scope.Scope
let active = true
update([...hooks(), callback])
const dispose = Effect.sync(() => {
if (!active) return
active = false
update(hooks().filter((item) => item !== callback))
})
yield* Scope.addFinalizer(scope, dispose)
return { dispose }
})
const run = Effect.fnUntraced(function* <Event>(
hooks: readonly ((event: Event) => Effect.Effect<void> | void)[],
event: Event,
) {
for (const hook of hooks) {
const result = hook(event)
if (Effect.isEffect(result)) yield* result
}
return event
})
const service = Service.of({
hook: {
sdk: register(
() => sdkHooks,
(next) => (sdkHooks = next),
),
language: register(
() => languageHooks,
(next) => (languageHooks = next),
),
},
runSDK: (event) => run(sdkHooks, event),
runLanguage: (event) => run(languageHooks, event),
language: Effect.fn("AISDK.language")(function* (model) { language: Effect.fn("AISDK.language")(function* (model) {
const key = `${model.providerID}/${model.id}/${model.request.variant ?? "default"}` const key = `${model.providerID}/${model.id}/${model.request.variant ?? "default"}`
const existing = languages.get(key) const existing = languages.get(key)
@ -148,26 +212,14 @@ export const layer = Layer.effect(
}) })
const sdk = const sdk =
sdks.get(sdkKey) ?? sdks.get(sdkKey) ??
(yield* plugin (yield* service.runSDK({ model, package: model.api.package, options }).pipe(initError(model.providerID))).sdk
.trigger("aisdk.sdk", { model, package: model.api.package, options }, {})
.pipe(initError(model.providerID))).sdk
if (!sdk) if (!sdk)
return yield* new InitError({ return yield* new InitError({
providerID: model.providerID, providerID: model.providerID,
cause: new Error("No AISDK provider plugin returned an SDK"), cause: new Error("No AISDK provider plugin returned an SDK"),
}) })
sdks.set(sdkKey, sdk) sdks.set(sdkKey, sdk)
const result = yield* plugin const result = yield* service.runLanguage({ model, sdk, options }).pipe(initError(model.providerID))
.trigger(
"aisdk.language",
{
model,
sdk,
options,
},
{},
)
.pipe(initError(model.providerID))
const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.api.id)).pipe( const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.api.id)).pipe(
initError(model.providerID), initError(model.providerID),
) )
@ -175,7 +227,8 @@ export const layer = Layer.effect(
return language return language
}), }),
}) })
return service
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))) export const defaultLayer = locationLayer

View file

@ -1,36 +1,21 @@
export * as Catalog from "./catalog" export * as Catalog from "./catalog"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema, Scope, Stream } from "effect" import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { ModelRequest } from "./model-request" import { ModelRequest } from "./model-request"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider" import { ProviderV2 } from "./provider"
import { Location } from "./location"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { Policy } from "./policy" import { Policy } from "./policy"
import { State } from "./state" import { State } from "./state"
import { Integration } from "./integration" import { Integration } from "./integration"
export type ProviderRecord = { export type ProviderRecord = {
provider: ProviderV2.Info provider: ProviderV2.MutableInfo
models: Map<ModelV2.ID, ModelV2.Info> models: Map<ModelV2.ID, ModelV2.MutableInfo>
} }
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID } export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"CatalogV2.ProviderNotFound",
{
providerID: ProviderV2.ID,
},
) {}
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("CatalogV2.ModelNotFound", {
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
}) {}
export const PolicyActions = Schema.Literals(["provider.use"]) export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = { export const Event = {
@ -42,16 +27,16 @@ type Data = {
defaultModel?: DefaultModel defaultModel?: DefaultModel
} }
export type Editor = { export type Draft = {
provider: { provider: {
list: () => readonly ProviderRecord[] list: () => readonly ProviderRecord[]
get: (providerID: ProviderV2.ID) => ProviderRecord | undefined get: (providerID: ProviderV2.ID) => ProviderRecord | undefined
update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void
remove: (providerID: ProviderV2.ID) => void remove: (providerID: ProviderV2.ID) => void
} }
model: { model: {
get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: ModelV2.MutableInfo) => void) => void
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
default: { default: {
get: () => DefaultModel | undefined get: () => DefaultModel | undefined
@ -60,43 +45,34 @@ export type Editor = {
} }
} }
export interface Interface { export interface Interface extends State.Transformable<Draft> {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly provider: { readonly provider: {
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError> readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined>
readonly all: () => Effect.Effect<ProviderV2.Info[]> readonly all: () => Effect.Effect<ProviderV2.Info[]>
readonly available: () => Effect.Effect<ProviderV2.Info[]> readonly available: () => Effect.Effect<ProviderV2.Info[]>
} }
readonly model: { readonly model: {
readonly get: ( readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info | undefined>
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
readonly all: () => Effect.Effect<ModelV2.Info[]> readonly all: () => Effect.Effect<ModelV2.Info[]>
readonly available: () => Effect.Effect<ModelV2.Info[]> readonly available: () => Effect.Effect<ModelV2.Info[]>
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>> readonly default: () => Effect.Effect<ModelV2.Info | undefined>
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<Option.Option<ModelV2.Info>> readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
} }
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
enableMapSet()
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const location = yield* Location.Service
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const policy = yield* Policy.Service const policy = yield* Policy.Service
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const scope = yield* Scope.Scope
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => { const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
if (provider.disabled) return false if (provider.disabled) return false
if (typeof provider.request.body.apiKey === "string") return true if (typeof provider.request.body.apiKey === "string") return true
if (connected) return true if (integration?.connections.length) return true
return !integration return !integration
} }
@ -120,32 +96,26 @@ export const layer = Layer.effect(
}) })
} }
function* getRecord(providerID: ProviderV2.ID) { const normalizeApi = (item: ProviderV2.MutableInfo | ModelV2.MutableInfo) => {
const match = state.get().providers.get(providerID)
if (!match) return yield* new ProviderNotFoundError({ providerID })
return match
}
const normalizeApi = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
if (typeof item.request.body.baseURL !== "string") return if (typeof item.request.body.baseURL !== "string") return
item.api.url = item.request.body.baseURL item.api.url = item.request.body.baseURL
delete item.request.body.baseURL delete item.request.body.baseURL
} }
const state = State.create<Data, Editor>({ const state = State.create<Data, Draft>({
initial: () => ({ providers: new Map() }), initial: () => ({ providers: new Map() }),
editor: (draft) => { draft: (draft) => {
const result: Editor = { const result: Draft = {
provider: { provider: {
list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[], list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[],
get: (providerID) => draft.providers.get(providerID), get: (providerID) => draft.providers.get(providerID),
update: (providerID, fn) => { update: (providerID, fn) => {
let current = draft.providers.get(providerID) let current = draft.providers.get(providerID)
if (!current) { if (!current) {
current = castDraft({ current = {
provider: ProviderV2.Info.empty(providerID), provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
models: new Map<ModelV2.ID, ModelV2.Info>(), models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
}) }
draft.providers.set(providerID, current) draft.providers.set(providerID, current)
} }
fn(current.provider) fn(current.provider)
@ -160,13 +130,14 @@ export const layer = Layer.effect(
update: (providerID, modelID, fn) => { update: (providerID, modelID, fn) => {
let record = draft.providers.get(providerID) let record = draft.providers.get(providerID)
if (!record) { if (!record) {
record = castDraft({ record = {
provider: ProviderV2.Info.empty(providerID), provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
models: new Map<ModelV2.ID, ModelV2.Info>(), models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
}) }
draft.providers.set(providerID, record) draft.providers.set(providerID, record)
} }
const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID)) const model =
record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo)
if (!record.models.has(modelID)) record.models.set(modelID, model) if (!record.models.has(modelID)) record.models.set(modelID, model)
fn(model) fn(model)
model.id = modelID model.id = modelID
@ -186,8 +157,7 @@ export const layer = Layer.effect(
} }
return result return result
}, },
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) { finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
if (policy.hasStatements()) { if (policy.hasStatements()) {
for (const record of [...catalog.provider.list()]) { for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
@ -198,25 +168,13 @@ export const layer = Layer.effect(
yield* events.publish(Event.Updated, {}) yield* events.publish(Event.Updated, {})
}), }),
}) })
yield* events.subscribe(PluginV2.Event.Added).pipe(
// Plugin registries are location scoped even though the event bus is process scoped.
Stream.filter(
(event) =>
event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID,
),
Stream.runForEach((event) =>
state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
),
Effect.forkIn(scope, { startImmediately: true }),
)
const result: Interface = { const result: Interface = {
transform: state.transform, transform: state.transform,
reload: state.reload,
provider: { provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
const record = yield* getRecord(providerID) return state.get().providers.get(providerID)?.provider
return record.provider
}), }),
all: Effect.fn("CatalogV2.provider.all")(function* () { all: Effect.fn("CatalogV2.provider.all")(function* () {
@ -225,23 +183,18 @@ export const layer = Layer.effect(
available: Effect.fn("CatalogV2.provider.available")(function* () { available: Effect.fn("CatalogV2.provider.available")(function* () {
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration])) const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
const connections = yield* integrations.connection.list()
return (yield* result.provider.all()).filter((provider) => return (yield* result.provider.all()).filter((provider) =>
available( available(provider, active.get(Integration.ID.make(provider.id))),
provider,
active.get(Integration.ID.make(provider.id)),
connections.has(Integration.ID.make(provider.id)),
),
) )
}), }),
}, },
model: { model: {
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) { get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
const record = yield* getRecord(providerID) const record = state.get().providers.get(providerID)
if (!record) return
const model = record.models.get(modelID) const model = record.models.get(modelID)
if (!model) return yield* new ModelNotFoundError({ providerID, modelID }) return model && projectModel(model, record.provider)
return projectModel(model, record.provider)
}), }),
all: Effect.fn("CatalogV2.model.all")(function* () { all: Effect.fn("CatalogV2.model.all")(function* () {
@ -250,7 +203,7 @@ export const layer = Layer.effect(
Array.flatMap((record) => { Array.flatMap((record) => {
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider)) return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
}), }),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
) )
}), }),
@ -262,31 +215,30 @@ export const layer = Layer.effect(
default: Effect.fn("CatalogV2.model.default")(function* () { default: Effect.fn("CatalogV2.model.default")(function* () {
const defaultModel = state.get().defaultModel const defaultModel = state.get().defaultModel
if (defaultModel) { if (defaultModel) {
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option) const provider = yield* result.provider.get(defaultModel.providerID)
if ( if (provider && (yield* result.provider.available()).some((item) => item.id === provider.id)) {
Option.isSome(provider) && const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID)
(yield* result.provider.available()).some((item) => item.id === provider.value.id) if (model?.enabled) return model
) {
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
if (Option.isSome(model) && model.value.enabled) return model
} }
} }
return pipe( return Option.getOrUndefined(
yield* result.model.available(), pipe(
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), yield* result.model.available(),
Array.head, Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
Array.head,
),
) )
}), }),
small: Effect.fn("CatalogV2.model.small")(function* (providerID) { small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID) const record = state.get().providers.get(providerID)
if (!record) return Option.none<ModelV2.Info>() if (!record) return
const provider = record.provider const provider = record.provider
if (providerID === ProviderV2.ID.opencode) { if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano")) const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider)) if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
} }
const candidates = pipe( const candidates = pipe(
@ -302,7 +254,7 @@ export const layer = Layer.effect(
Array.map((model) => ({ Array.map((model) => ({
model, model,
cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999, cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30), age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30),
small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()), small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
})), })),
Array.filter((item) => item.cost > 0 && item.age <= 18), Array.filter((item) => item.cost > 0 && item.age <= 18),
@ -319,10 +271,12 @@ export const layer = Layer.effect(
) )
} }
return pipe( return Option.getOrUndefined(
candidates, pipe(
Array.filter((item) => item.small), candidates,
(items) => (items.length > 0 ? pick(items) : pick(candidates)), Array.filter((item) => item.small),
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
),
) )
}), }),
}, },
@ -336,6 +290,5 @@ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const locationLayer = layer.pipe( export const locationLayer = layer.pipe(
Layer.provideMerge(Integration.locationLayer), Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Policy.locationLayer), Layer.provideMerge(Policy.locationLayer),
) )

View file

@ -1,7 +1,6 @@
export * as CommandV2 from "./command" export * as CommandV2 from "./command"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema, Types } from "effect"
import { castDraft, type Draft } from "immer"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { State } from "./state" import { State } from "./state"
@ -15,19 +14,17 @@ export class Info extends Schema.Class<Info>("CommandV2.Info")({
}) {} }) {}
export type Data = { export type Data = {
commands: Map<string, Info> commands: Map<string, Types.DeepMutable<Info>>
} }
export type Editor = { export type Draft = {
list: () => readonly Info[] list: () => readonly Info[]
get: (name: string) => Info | undefined get: (name: string) => Info | undefined
update: (name: string, update: (command: Draft<Info>) => void) => void update: (name: string, update: (command: Types.DeepMutable<Info>) => void) => void
remove: (name: string) => void remove: (name: string) => void
} }
export interface Interface { export interface Interface extends State.Transformable<Draft> {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
readonly get: (name: string) => Effect.Effect<Info | undefined> readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
} }
@ -37,13 +34,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.sync(() => { Effect.sync(() => {
const state = State.create<Data, Editor>({ const state = State.create<Data, Draft>({
initial: () => ({ commands: new Map() }), initial: () => ({ commands: new Map() }),
editor: (draft) => ({ draft: (draft) => ({
list: () => Array.from(draft.commands.values()) as Info[], list: () => Array.from(draft.commands.values()) as Info[],
get: (name) => draft.commands.get(name), get: (name) => draft.commands.get(name),
update: (name, update) => { update: (name, update) => {
const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" })) const current = draft.commands.get(name) ?? (new Info({ name, template: "" }) as Types.DeepMutable<Info>)
if (!draft.commands.has(name)) draft.commands.set(name, current) if (!draft.commands.has(name)) draft.commands.set(name, current)
update(current) update(current)
current.name = name current.name = name
@ -55,7 +52,7 @@ export const layer = Layer.effect(
}) })
return Service.of({ return Service.of({
update: state.update, reload: state.reload,
transform: state.transform, transform: state.transform,
get: Effect.fn("CommandV2.get")(function* (name) { get: Effect.fn("CommandV2.get")(function* (name) {
return state.get().commands.get(name) return state.get().commands.get(name)

View file

@ -1,5 +1,6 @@
export * as ConfigAgentPlugin from "./agent" export * as ConfigAgentPlugin from "./agent"
import { define } from "../../plugin/internal"
import path from "path" import path from "path"
import { Effect, Option, Schema } from "effect" import { Effect, Option, Schema } from "effect"
import { AgentV2 } from "../../agent" import { AgentV2 } from "../../agent"
@ -8,7 +9,6 @@ import { ConfigAgent } from "../agent"
import { ConfigMarkdown } from "../markdown" import { ConfigMarkdown } from "../markdown"
import { FSUtil } from "../../fs-util" import { FSUtil } from "../../fs-util"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin"
import { ConfigAgentV1 } from "../../v1/config/agent" import { ConfigAgentV1 } from "../../v1/config/agent"
import { ConfigMigrateV1 } from "../../v1/config/migrate" import { ConfigMigrateV1 } from "../../v1/config/migrate"
@ -33,70 +33,70 @@ const agentKeys = new Set([
"permissions", "permissions",
]) ])
export const Plugin = PluginV2.define({ export const Plugin = define({
id: PluginV2.ID.make("config-agent"), id: "config-agent",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
const agent = yield* AgentV2.Service
const config = yield* Config.Service const config = yield* Config.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { yield* ctx.agent.transform(
if (entry.type === "document") return Effect.succeed([entry]) Effect.fn(function* (draft) {
return Effect.gen(function* () { const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
const files = yield* discover(fs, entry.path) if (entry.type === "document") return Effect.succeed([entry])
return yield* Effect.forEach(files, (file) => return Effect.gen(function* () {
fs.readFileStringSafe(file.filepath).pipe( const files = yield* discover(fs, entry.path)
Effect.map((content) => content && decode(file, content)), return yield* Effect.forEach(files, (file) =>
Effect.catch(() => Effect.succeed(undefined)), fs.readFileStringSafe(file.filepath).pipe(
), Effect.map((content) => content && decode(file, content)),
).pipe( Effect.catch(() => Effect.succeed(undefined)),
Effect.map((documents) => ),
documents.filter((document): document is Config.Document => document !== undefined), ).pipe(
), Effect.map((documents) =>
) documents.filter((document): document is Config.Document => document !== undefined),
}) ),
}).pipe(Effect.map((documents) => documents.flat())) )
yield* agent.update((editor) => {
const global = documents.flatMap((document) => document.info.permissions ?? [])
const configuredDefault = Config.latest(documents, "default_agent")
if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault))
for (const current of editor.list()) {
editor.update(current.id, (agent) => agent.permissions.push(...global))
}
for (const document of documents) {
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
const agentID = AgentV2.ID.make(id)
if (item.disabled) {
editor.remove(agentID)
continue
}
const exists = editor.get(agentID) !== undefined
editor.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...global)
if (item.model !== undefined) {
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
}
if (item.variant !== undefined && agent.model !== undefined) {
agent.model.variant = ModelV2.VariantID.make(item.variant)
}
if (item.request !== undefined) {
Object.assign(agent.request.headers, item.request.headers ?? {})
Object.assign(agent.request.body, item.request.body ?? {})
}
if (item.system !== undefined) agent.system = item.system
if (item.description !== undefined) agent.description = item.description
if (item.mode !== undefined) agent.mode = item.mode
if (item.hidden !== undefined) agent.hidden = item.hidden
if (item.color !== undefined) agent.color = item.color
if (item.steps !== undefined) agent.steps = item.steps
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
}) })
}).pipe(Effect.map((documents) => documents.flat()))
const global = documents.flatMap((document) => document.info.permissions ?? [])
const configuredDefault = Config.latest(documents, "default_agent")
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...global))
} }
}
}) for (const document of documents) {
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
const agentID = AgentV2.ID.make(id)
if (item.disabled) {
draft.remove(agentID)
continue
}
const exists = draft.get(agentID) !== undefined
draft.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...global)
if (item.model !== undefined) {
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
}
if (item.variant !== undefined && agent.model !== undefined) {
agent.model.variant = ModelV2.VariantID.make(item.variant)
}
if (item.request !== undefined) {
Object.assign(agent.request.headers, item.request.headers ?? {})
Object.assign(agent.request.body, item.request.body ?? {})
}
if (item.system !== undefined) agent.system = item.system
if (item.description !== undefined) agent.description = item.description
if (item.mode !== undefined) agent.mode = item.mode
if (item.hidden !== undefined) agent.hidden = item.hidden
if (item.color !== undefined) agent.color = item.color
if (item.steps !== undefined) agent.steps = item.steps
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
})
}
}
}),
)
}), }),
}) })

View file

@ -1,52 +1,51 @@
export * as ConfigCommandPlugin from "./command" export * as ConfigCommandPlugin from "./command"
import { define } from "../../plugin/internal"
import path from "path" import path from "path"
import { Effect, Option, Schema } from "effect" import { Effect, Option, Schema } from "effect"
import { CommandV2 } from "../../command" import { CommandV2 } from "../../command"
import { Config } from "../../config" import { Config } from "../../config"
import { FSUtil } from "../../fs-util" import { FSUtil } from "../../fs-util"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin"
import { ConfigCommand } from "../command" import { ConfigCommand } from "../command"
import { ConfigMarkdown } from "../markdown" import { ConfigMarkdown } from "../markdown"
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info) const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
export const Plugin = PluginV2.define({ export const Plugin = define({
id: PluginV2.ID.make("config-command"), id: "config-command",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
const command = yield* CommandV2.Service
const config = yield* Config.Service const config = yield* Config.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const transform = yield* command.transform() yield* ctx.command.transform(
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { Effect.fn(function* (draft) {
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
return loadDirectory(fs, entry.path).pipe( if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
Effect.map((commands) => [ return loadDirectory(fs, entry.path).pipe(
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, Effect.map((commands) => [
]), { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
) ]),
}).pipe(Effect.map((documents) => documents.flat())) )
}).pipe(Effect.map((documents) => documents.flat()))
yield* transform((editor) => { for (const document of documents) {
for (const document of documents) { for (const [name, command] of Object.entries(document.commands ?? {})) {
for (const [name, command] of Object.entries(document.commands ?? {})) { draft.update(name, (item) => {
editor.update(name, (item) => { item.template = command.template
item.template = command.template if (command.description !== undefined) item.description = command.description
if (command.description !== undefined) item.description = command.description if (command.agent !== undefined) item.agent = command.agent
if (command.agent !== undefined) item.agent = command.agent if (command.model !== undefined) {
if (command.model !== undefined) { const model = ModelV2.parse(command.model)
const model = ModelV2.parse(command.model) item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant } }
} if (command.variant !== undefined && item.model !== undefined) {
if (command.variant !== undefined && item.model !== undefined) { item.model.variant = ModelV2.VariantID.make(command.variant)
item.model.variant = ModelV2.VariantID.make(command.variant) }
} if (command.subtask !== undefined) item.subtask = command.subtask
if (command.subtask !== undefined) item.subtask = command.subtask })
}) }
} }
} }),
}) )
}), }),
}) })

View file

@ -0,0 +1,91 @@
export * as ConfigExternalPlugin from "./external"
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
import { Effect, Schema } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../../config"
import { FSUtil } from "../../fs-util"
import { Location } from "../../location"
import { Npm } from "../../npm"
import { define } from "../../plugin/internal"
import { PluginPromise } from "../../plugin/promise"
const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
effect: Schema.declare<EffectPlugin["effect"]>(
(input): input is EffectPlugin["effect"] => typeof input === "function",
),
}),
Schema.Struct({
id: Schema.String,
setup: Schema.declare<PromisePlugin["setup"]>(
(input): input is PromisePlugin["setup"] => typeof input === "function",
),
}),
]),
})
export const Plugin = define({
id: "config-plugin",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
yield* Effect.gen(function* () {
const configured: { package: string; options?: Record<string, any> }[] = []
for (const entry of yield* config.entries()) {
if (entry.type === "document") {
const directory = entry.path ? path.dirname(entry.path) : location.directory
for (const item of entry.info.plugins ?? []) {
const ref = typeof item === "string" ? { package: item } : item
const packageName = (() => {
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
if (ref.package.startsWith("./") || ref.package.startsWith("../")) {
return path.resolve(directory, ref.package)
}
return ref.package
})()
configured.push({ package: packageName, options: ref.options })
}
}
if (entry.type === "directory") {
const files = yield* fs
.glob("{plugin,plugins}/*.{ts,js}", {
cwd: entry.path,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
files.sort()
for (const file of files) configured.push({ package: file })
}
}
for (const ref of configured) {
yield* Effect.gen(function* () {
const entrypoint = path.isAbsolute(ref.package)
? pathToFileURL(ref.package).href
: (yield* npm.add(ref.package)).entrypoint
if (!entrypoint) return
const mod = yield* Effect.promise(() => import(entrypoint))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
yield* ctx.plugin.add({
id: plugin.id,
effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }),
})
}).pipe(Effect.ignoreCause)
}
}).pipe(Effect.forkScoped({ startImmediately: true }))
}),
})

View file

@ -1,123 +1,124 @@
export * as ConfigProviderPlugin from "./provider" export * as ConfigProviderPlugin from "./provider"
import { define } from "../../plugin/internal"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "../../catalog"
import { Config } from "../../config" import { Config } from "../../config"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request" import { ModelRequest } from "../../model-request"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
export const Plugin = PluginV2.define({ export const Plugin = define({
id: PluginV2.ID.make("config-provider"), id: "config-provider",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
const catalog = yield* Catalog.Service
const config = yield* Config.Service const config = yield* Config.Service
const integrations = yield* Integration.Service yield* ctx.integration.transform(
const transform = yield* catalog.transform() Effect.fn(function* (integrations) {
const integrationTransform = yield* integrations.transform() const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
const entries = yield* config.entries() const configuredIntegrations = new Set(
const files = entries.filter((entry): entry is Config.Document => entry.type === "document") files.flatMap((file) =>
const configuredIntegrations = new Set( Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
files.flatMap((file) => provider.env === undefined ? [] : [id],
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])), ),
), ),
) )
yield* integrationTransform((integrations) => { for (const file of files) {
for (const file of files) { for (const [id, item] of Object.entries(file.info.providers ?? {})) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) { const integrationID = id
const integrationID = Integration.ID.make(id) if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue integrations.update(integrationID, (integration) => {
integrations.update(integrationID, (integration) => { integration.name = item.name ?? integration.name
integration.name = item.name ?? integration.name
})
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
}) })
} if (item.env !== undefined) {
} integrations.method.update({
} integrationID,
}) method: { type: "env", names: [...item.env] },
})
yield* transform((catalog) => {
const configuredDefault = Config.latest(entries, "model")
if (configuredDefault !== undefined) {
const model = ModelV2.parse(configuredDefault)
catalog.model.default.set(model.providerID, model.modelID)
}
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const providerID = ProviderV2.ID.make(id)
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) {
Object.assign(provider.request.headers, item.request.headers)
Object.assign(provider.request.body, item.request.body)
} }
})
const providerApi = catalog.provider.get(providerID)?.provider.api
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, ModelV2.ID.make(id), (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
}
if (config.request !== undefined) {
ModelRequest.assign(model.request, {
headers: config.request.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
})
if (config.request.variant !== undefined) model.request.variant = config.request.variant
}
if (config.variants !== undefined) {
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = {
id: variant.id,
headers: {},
body: {},
generation: {},
options: {},
}
model.variants.push(existing)
}
ModelRequest.assign(existing, {
headers: variant.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
})
}
}
if (config.cost !== undefined) {
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? 0,
write: cost.cache?.write ?? 0,
},
}))
}
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
} }
} }
} }),
}) )
yield* ctx.catalog.transform(
Effect.fn(function* (catalog) {
const entries = yield* config.entries()
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredDefault = Config.latest(entries, "model")
if (configuredDefault !== undefined) {
const model = ModelV2.parse(configuredDefault)
catalog.model.default.set(model.providerID, model.modelID)
}
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const providerID = id
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) {
Object.assign(provider.request.headers, item.request.headers)
Object.assign(provider.request.body, item.request.body)
}
})
const providerApi = catalog.provider.get(providerID)?.provider.api
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
}
if (config.request !== undefined) {
ModelRequest.assign(model.request, {
headers: config.request.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
})
if (config.request.variant !== undefined) model.request.variant = config.request.variant
}
if (config.variants !== undefined) {
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = {
id: variant.id,
headers: {},
body: {},
generation: {},
options: {},
}
model.variants.push(existing)
}
ModelRequest.assign(existing, {
headers: variant.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
})
}
}
if (config.cost !== undefined) {
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? 0,
write: cost.cache?.write ?? 0,
},
}))
}
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
}
}
}
}),
)
}), }),
}) })

View file

@ -1,57 +1,56 @@
export * as ConfigReferencePlugin from "./reference" export * as ConfigReferencePlugin from "./reference"
import { define } from "../../plugin/internal"
import path from "path" import path from "path"
import { Effect } from "effect" import { Effect } from "effect"
import { Config } from "../../config" import { Config } from "../../config"
import { ConfigReference } from "../reference" import { ConfigReference } from "../reference"
import { Global } from "../../global"
import { Location } from "../../location"
import { PluginV2 } from "../../plugin"
import { Reference } from "../../reference" import { Reference } from "../../reference"
import { AbsolutePath } from "../../schema" import { AbsolutePath } from "../../schema"
import { Global } from "../../global"
import { Location } from "../../location"
export const Plugin = { export const Plugin = define({
id: PluginV2.ID.make("core/config-reference"), id: "core/config-reference",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service const config = yield* Config.Service
const global = yield* Global.Service
const location = yield* Location.Service const location = yield* Location.Service
const references = yield* Reference.Service const global = yield* Global.Service
const update = yield* references.transform() yield* ctx.reference.transform(
const entries = new Map<string, Reference.Source>() Effect.fn(function* (draft) {
for (const doc of (yield* config.entries()).filter( const entries = new Map<string, Reference.Source>()
(entry): entry is Config.Document => entry.type === "document", for (const doc of (yield* config.entries()).filter(
)) { (entry): entry is Config.Document => entry.type === "document",
const directory = doc.path ? path.dirname(doc.path) : location.directory )) {
for (const [name, entry] of Object.entries(doc.info.references ?? {})) { const directory = doc.path ? path.dirname(doc.path) : location.directory
if (!validAlias(name)) continue for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
entries.set( if (!validAlias(name)) continue
name, entries.set(
local(entry) name,
? new Reference.LocalSource({ local(entry)
type: "local", ? new Reference.LocalSource({
path: AbsolutePath.make( type: "local",
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path), path: AbsolutePath.make(
), localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
description: typeof entry === "string" ? undefined : entry.description, ),
hidden: typeof entry === "string" ? undefined : entry.hidden, description: typeof entry === "string" ? undefined : entry.description,
}) hidden: typeof entry === "string" ? undefined : entry.hidden,
: new Reference.GitSource({ })
type: "git", : new Reference.GitSource({
repository: typeof entry === "string" ? entry : entry.repository, type: "git",
branch: typeof entry === "string" ? undefined : entry.branch, repository: typeof entry === "string" ? entry : entry.repository,
description: typeof entry === "string" ? undefined : entry.description, branch: typeof entry === "string" ? undefined : entry.branch,
hidden: typeof entry === "string" ? undefined : entry.hidden, description: typeof entry === "string" ? undefined : entry.description,
}), hidden: typeof entry === "string" ? undefined : entry.hidden,
) }),
} )
} }
}
yield* update((editor) => { for (const [name, source] of entries) draft.add(name, source)
for (const [name, source] of entries) editor.add(name, source) }),
}) )
}), }),
} })
function validAlias(name: string) { function validAlias(name: string) {
return name.length > 0 && !/[\/\s`,]/.test(name) return name.length > 0 && !/[\/\s`,]/.test(name)

View file

@ -1,48 +1,47 @@
export * as ConfigSkillPlugin from "./skill" export * as ConfigSkillPlugin from "./skill"
import { define } from "../../plugin/internal"
import path from "path" import path from "path"
import { Effect } from "effect" import { Effect } from "effect"
import { Config } from "../../config" import { Config } from "../../config"
import { Global } from "../../global"
import { Location } from "../../location"
import { PluginV2 } from "../../plugin"
import { AbsolutePath } from "../../schema" import { AbsolutePath } from "../../schema"
import { SkillV2 } from "../../skill" import { SkillV2 } from "../../skill"
import { Global } from "../../global"
import { Location } from "../../location"
export const Plugin = PluginV2.define({ export const Plugin = define({
id: PluginV2.ID.make("config-skill"), id: "config-skill",
effect: Effect.gen(function* () { effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service const config = yield* Config.Service
const global = yield* Global.Service const global = yield* Global.Service
const location = yield* Location.Service const location = yield* Location.Service
const skill = yield* SkillV2.Service yield* ctx.skill.transform(
const transform = yield* skill.transform() Effect.fn(function* (draft) {
const entries = yield* config.entries() const entries = yield* config.entries()
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
for (const directory of directories) {
yield* transform((editor) => { draft.source(
for (const directory of directories) { new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
editor.source( )
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), draft.source(
) new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
editor.source( )
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
)
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
editor.source(new SkillV2.UrlSource({ type: "url", url: item }))
continue
} }
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item for (const item of items) {
editor.source( if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
new SkillV2.DirectorySource({ draft.source(new SkillV2.UrlSource({ type: "url", url: item }))
type: "directory", continue
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)), }
}), const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
) draft.source(
} new SkillV2.DirectorySource({
}) type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
)
}
}),
)
}), }),
}) })

View file

@ -48,7 +48,7 @@ export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSource
{ {
directory: AbsolutePath, directory: AbsolutePath,
message: Schema.String, message: Schema.String,
cause: Schema.optional(Schema.Defect), cause: Schema.optional(Schema.Defect()),
}, },
) {} ) {}

View file

@ -29,33 +29,33 @@ export class Key extends Schema.Class<Key>("Credential.Key")({
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {} }) {}
export const Info = Schema.Union([OAuth, Key]) export const Value = Schema.Union([OAuth, Key])
.pipe(Schema.toTaggedUnion("type")) .pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Credential.Info" }) .annotate({ identifier: "Credential.Value" })
export type Info = Schema.Schema.Type<typeof Info> export type Value = Schema.Schema.Type<typeof Value>
export class Stored extends Schema.Class<Stored>("Credential.Stored")({ export class Info extends Schema.Class<Info>("Credential.Info")({
id: ID, id: ID,
integrationID: IntegrationSchema.ID, integrationID: IntegrationSchema.ID,
label: Schema.String, label: Schema.String,
value: Info, value: Value,
}) {} }) {}
export interface Interface { export interface Interface {
/** Returns every stored credential. */ /** Returns every stored credential. */
readonly all: () => Effect.Effect<Stored[]> readonly all: () => Effect.Effect<Info[]>
/** Returns stored credentials belonging to one integration. */ /** Returns stored credentials belonging to one integration. */
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]> readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Info[]>
/** Returns one stored credential by ID. */ /** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Stored | undefined> readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Replaces any credential for an integration and returns the new record. */ /** Replaces any credential for an integration and returns the new record. */
readonly create: (input: { readonly create: (input: {
readonly integrationID: IntegrationSchema.ID readonly integrationID: IntegrationSchema.ID
readonly value: Info readonly value: Value
readonly label?: string readonly label?: string
}) => Effect.Effect<Stored> }) => Effect.Effect<Info>
/** Updates the label or secret value of a stored credential. */ /** Updates the label or secret value of a stored credential. */
readonly update: (id: ID, updates: Partial<Pick<Stored, "label" | "value">>) => Effect.Effect<void> readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
/** Removes a stored credential. */ /** Removes a stored credential. */
readonly remove: (id: ID) => Effect.Effect<void> readonly remove: (id: ID) => Effect.Effect<void>
} }
@ -66,10 +66,10 @@ export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const { db } = yield* Database.Service const { db } = yield* Database.Service
const decode = Schema.decodeUnknownSync(Info) const decode = Schema.decodeUnknownSync(Value)
const stored = (row: typeof CredentialTable.$inferSelect) => { const stored = (row: typeof CredentialTable.$inferSelect) => {
if (!row.integration_id) return if (!row.integration_id) return
return new Stored({ return new Info({
id: row.id, id: row.id,
integrationID: row.integration_id, integrationID: row.integration_id,
label: row.label, label: row.label,
@ -106,7 +106,7 @@ export const layer = Layer.effect(
return row ? stored(row) : undefined return row ? stored(row) : undefined
}), }),
create: Effect.fn("Credential.create")(function* (input) { create: Effect.fn("Credential.create")(function* (input) {
const credential = new Stored({ const credential = new Info({
id: ID.create(), id: ID.create(),
integrationID: input.integrationID, integrationID: input.integrationID,
label: input.label ?? "default", label: input.label ?? "default",

View file

@ -7,7 +7,7 @@ export const CredentialTable = sqliteTable("credential", {
id: text().$type<Credential.ID>().primaryKey(), id: text().$type<Credential.ID>().primaryKey(),
integration_id: text().$type<IntegrationSchema.ID>(), integration_id: text().$type<IntegrationSchema.ID>(),
label: text().notNull(), label: text().notNull(),
value: text({ mode: "json" }).$type<Credential.Info>().notNull(), value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
connector_id: text(), connector_id: text(),
method_id: text(), method_id: text(),
active: integer({ mode: "boolean" }), active: integer({ mode: "boolean" }),

View file

@ -37,5 +37,8 @@ export const migrations = (
import("./migration/20260611035744_credential"), import("./migration/20260611035744_credential"),
import("./migration/20260611192811_lush_chimera"), import("./migration/20260611192811_lush_chimera"),
import("./migration/20260612174303_project_dir_strategy"), import("./migration/20260612174303_project_dir_strategy"),
import("./migration/20260622142730_simplify_session_context_epoch"),
import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"),
]) ])
).map((module) => module.default) satisfies DatabaseMigration.Migration[] ).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260622142730_simplify_session_context_epoch",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`agent\`;`)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`replacement_seq\`;`)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`revision\`;`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -0,0 +1,17 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260622170816_reset_v2_session_state",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_context_epoch\`;`)
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
yield* tx.run(`DELETE FROM \`workspace\`;`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -0,0 +1,17 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260622202450_simplify_session_input",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_context_epoch\`;`)
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
yield* tx.run(`DELETE FROM \`workspace\`;`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -149,11 +149,8 @@ export default {
CREATE TABLE \`session_context_epoch\` ( CREATE TABLE \`session_context_epoch\` (
\`session_id\` text PRIMARY KEY, \`session_id\` text PRIMARY KEY,
\`baseline\` text NOT NULL, \`baseline\` text NOT NULL,
\`agent\` text DEFAULT 'build' NOT NULL,
\`snapshot\` text NOT NULL, \`snapshot\` text NOT NULL,
\`baseline_seq\` integer NOT NULL, \`baseline_seq\` integer NOT NULL,
\`replacement_seq\` integer,
\`revision\` integer DEFAULT 0 NOT NULL,
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
); );
`) `)

View file

@ -5,7 +5,7 @@ import { and, asc, eq, gt } from "drizzle-orm"
import { Database } from "./database/database" import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql" import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location" import { Location } from "./location"
import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema" import { externalID, type ExternalID, withStatics } from "./schema"
import { Identifier } from "./util/identifier" import { Identifier } from "./util/identifier"
import { LayerNode } from "./effect/layer-node" import { LayerNode } from "./effect/layer-node"
import { isDeepStrictEqual } from "node:util" import { isDeepStrictEqual } from "node:util"
@ -19,16 +19,9 @@ export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
) )
export type ID = typeof ID.Type export type ID = typeof ID.Type
/**
* Durable aggregate continuation position for embedded replay streams.
* TODO: Decide whether a future HTTP / SDK surface should expose an opaque cursor instead.
*/
export const Cursor = NonNegativeInt.pipe(Schema.brand("EventV2.Cursor"))
export type Cursor = typeof Cursor.Type
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = { export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
readonly type: Type readonly type: Type
readonly sync?: { readonly durable?: {
readonly version: number readonly version: number
readonly aggregate: string readonly aggregate: string
} }
@ -41,22 +34,31 @@ export type Payload<D extends Definition = Definition> = {
readonly id: ID readonly id: ID
readonly type: D["type"] readonly type: D["type"]
readonly data: Data<D> readonly data: Data<D>
/** Durable aggregate order, populated while synchronized events are projected. */ readonly durable?: {
readonly seq?: number readonly aggregateID: string
readonly version?: number readonly seq: number
readonly version: number
}
readonly location?: Location.Ref readonly location?: Location.Ref
readonly metadata?: Record<string, unknown> readonly metadata?: Record<string, unknown>
/** Internal replay marker for projectors that own non-replicated operational state. */
readonly replay?: boolean
} }
export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void> export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
type AnyProjector = (event: Payload) => Effect.Effect<void>
export type CommitGuard = (event: Payload) => Effect.Effect<void>
export type Listener = (event: Payload) => Effect.Effect<void>
export type Sync = (event: Payload) => Effect.Effect<void>
export type Unsubscribe = Effect.Effect<void> export type Unsubscribe = Effect.Effect<void>
export const latestSequence = Effect.fn("EventV2.latestSequence")(function* (
db: Database.Interface["db"],
aggregateID: string,
) {
const row = yield* db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
return row?.seq ?? -1
})
export type SerializedEvent = { export type SerializedEvent = {
readonly id: ID readonly id: ID
readonly type: string readonly type: string
@ -65,13 +67,8 @@ export type SerializedEvent = {
readonly data: Record<string, unknown> readonly data: Record<string, unknown>
} }
export type CursorEvent<E extends Payload = Payload> = { export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDurableEventError>()(
readonly cursor: Cursor "EventV2.InvalidDurableEvent",
readonly event: E
}
export class InvalidSyncEventError extends Schema.TaggedErrorClass<InvalidSyncEventError>()(
"EventV2.InvalidSyncEvent",
{ {
type: Schema.String, type: Schema.String,
message: Schema.String, message: Schema.String,
@ -83,19 +80,11 @@ export function versionedType(type: string, version: number) {
} }
export const registry = new Map<string, Definition>() export const registry = new Map<string, Definition>()
type SyncDefinition = Definition & { const durableRegistry = new Map<string, Definition>()
readonly sync: NonNullable<Definition["sync"]>
readonly encode: (data: unknown) => unknown
readonly decode: (data: unknown) => unknown
}
const syncRegistry = new Map<string, SyncDefinition>()
// Synchronized events cross a JSON boundary, so their data schemas must encode and decode without services.
const syncCodec = (definition: Definition) => definition.data as Schema.Codec<unknown, unknown, never, never>
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: { export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
readonly type: Type readonly type: Type
readonly sync?: { readonly durable?: {
readonly version: number readonly version: number
readonly aggregate: string readonly aggregate: string
} }
@ -106,28 +95,25 @@ export function define<const Type extends string, Fields extends Schema.Struct.F
id: ID, id: ID,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type), type: Schema.Literal(input.type),
version: Schema.optional(Schema.Number), durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })),
location: Schema.optional(Location.Ref), location: Schema.optional(Location.Ref),
data: Data, data: Data,
}).annotate({ identifier: input.type }) }).annotate({ identifier: input.type })
const definition = Object.assign(Payload, { const definition = Object.assign(Payload, {
type: input.type, type: input.type,
...(input.sync === undefined ? {} : { sync: input.sync }), ...(input.durable === undefined ? {} : { durable: input.durable }),
data: Data, data: Data,
}) })
const existing = registry.get(input.type) const existing = registry.get(input.type)
if (input.sync === undefined || existing?.sync === undefined || input.sync.version >= existing.sync.version) { if (
input.durable === undefined ||
existing?.durable === undefined ||
input.durable.version >= existing.durable.version
) {
registry.set(input.type, definition) registry.set(input.type, definition)
} }
if (input.sync) if (input.durable) durableRegistry.set(versionedType(input.type, input.durable.version), definition)
syncRegistry.set(
versionedType(input.type, input.sync.version),
Object.assign(definition, {
encode: Schema.encodeUnknownSync(syncCodec(definition)),
decode: Schema.decodeUnknownSync(syncCodec(definition)),
}) as SyncDefinition,
)
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
Definition<Type, Schema.Struct<Fields>> Definition<Type, Schema.Struct<Fields>>
} }
@ -140,7 +126,7 @@ export interface PublishOptions {
readonly id?: ID readonly id?: ID
readonly metadata?: Record<string, unknown> readonly metadata?: Record<string, unknown>
readonly location?: Location.Ref readonly location?: Location.Ref
/** Local operational projection committed atomically with a new synchronized event. Not replayed or serialized. */ /** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
readonly commit?: (seq: number) => Effect.Effect<void> readonly commit?: (seq: number) => Effect.Effect<void>
} }
@ -152,14 +138,10 @@ export interface Interface {
) => Effect.Effect<Payload<D>> ) => Effect.Effect<Payload<D>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>> readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
readonly all: () => Stream.Stream<Payload> readonly all: () => Stream.Stream<Payload>
readonly aggregateEvents: (input: { readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream<Payload>
readonly aggregateID: string /** @deprecated Use `all()` and consume the returned stream. */
readonly after?: Cursor readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
}) => Stream.Stream<CursorEvent> readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
readonly project: <D extends Definition>(definition: D, projector: Projector<D>) => Effect.Effect<void>
readonly replay: ( readonly replay: (
event: SerializedEvent, event: SerializedEvent,
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
@ -182,37 +164,37 @@ export const layerWith = (options?: LayerOptions) =>
Layer.effect( Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const all = yield* PubSub.unbounded<Payload>() const pubsub = {
const synchronized = new Map<string, Set<PubSub.PubSub<void>>>() all: yield* PubSub.unbounded<Payload>(),
const typed = new Map<string, PubSub.PubSub<Payload>>() durable: new Map<string, Set<PubSub.PubSub<void>>>(),
const projectors = new Map<string, AnyProjector[]>() typed: new Map<string, PubSub.PubSub<Payload>>(),
const commitGuards = new Array<CommitGuard>() }
const listeners = new Array<Listener>() const projectors = new Map<string, Subscriber[]>()
const syncHandlers = new Array<Sync>() const listeners = new Array<Subscriber>()
const { db } = yield* Database.Service const { db } = yield* Database.Service
const getOrCreate = (definition: Definition) => const getOrCreate = (definition: Definition) =>
Effect.gen(function* () { Effect.gen(function* () {
const existing = typed.get(definition.type) const existing = pubsub.typed.get(definition.type)
if (existing) return existing if (existing) return existing
const pubsub = yield* PubSub.unbounded<Payload>() const created = yield* PubSub.unbounded<Payload>()
typed.set(definition.type, pubsub) pubsub.typed.set(definition.type, created)
return pubsub return created
}) })
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
Effect.gen(function* () { Effect.gen(function* () {
yield* PubSub.shutdown(all) yield* PubSub.shutdown(pubsub.all)
yield* Effect.forEach( yield* Effect.forEach(
synchronized.values(), pubsub.durable.values(),
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }), (pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
{ discard: true }, { discard: true },
) )
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true }) yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true })
}), }),
) )
function commitSyncEvent( function commitDurableEvent(
event: Payload, event: Payload,
input?: { input?: {
readonly seq: number readonly seq: number
@ -224,28 +206,20 @@ export const layerWith = (options?: LayerOptions) =>
) { ) {
return Effect.gen(function* () { return Effect.gen(function* () {
const definition = registry.get(event.type) const definition = registry.get(event.type)
const sync = definition?.sync const durable = definition?.durable
if (sync) { if (durable) {
if (event.version !== sync.version) { const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected event version ${sync.version}, got ${event.version}`,
}),
)
}
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
if (typeof aggregateID !== "string") { if (typeof aggregateID !== "string") {
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: event.type, type: event.type,
message: `Expected string aggregate field ${sync.aggregate}`, message: `Expected string aggregate field ${durable.aggregate}`,
}), }),
) )
} else { } else {
if (input && input.aggregateID !== aggregateID) { if (input && input.aggregateID !== aggregateID) {
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: event.type, type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`, message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}), }),
@ -265,12 +239,12 @@ export const layerWith = (options?: LayerOptions) =>
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
const latest = row?.seq ?? -1 const latest = row?.seq ?? -1
const encoded = syncRegistry const encoded = Schema.encodeUnknownSync(
.get(versionedType(definition.type, sync.version))! definition.data as Schema.Codec<unknown, unknown, never, never>,
.encode(event.data) as Record<string, unknown> )(event.data) as Record<string, unknown>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) { if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: event.type, type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`, message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}), }),
@ -285,7 +259,7 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie) .pipe(Effect.orDie)
if ( if (
stored?.id === event.id && stored?.id === event.id &&
stored.type === versionedType(definition.type, sync.version) && stored.type === versionedType(definition.type, durable.version) &&
isDeepStrictEqual(stored.data, encoded) isDeepStrictEqual(stored.data, encoded)
) { ) {
if (input.ownerID && row?.ownerID == null) { if (input.ownerID && row?.ownerID == null) {
@ -299,7 +273,7 @@ export const layerWith = (options?: LayerOptions) =>
return return
} }
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: event.type, type: event.type,
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`, message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
}), }),
@ -311,7 +285,7 @@ export const layerWith = (options?: LayerOptions) =>
const seq = input?.seq ?? latest + 1 const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) { if (input && seq !== latest + 1) {
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: event.type, type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`, message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}), }),
@ -325,16 +299,17 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (stored) if (stored)
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: event.type, type: event.type,
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`, message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}), }),
) )
for (const guard of commitGuards) { const committed = {
yield* guard(event) ...event,
} durable: { aggregateID, seq, version: durable.version },
} as Payload
for (const projector of list) { for (const projector of list) {
yield* projector({ ...event, seq } as Payload) yield* projector(committed)
} }
if (commit) yield* commit(seq) if (commit) yield* commit(seq)
yield* db yield* db
@ -356,7 +331,7 @@ export const layerWith = (options?: LayerOptions) =>
id: event.id, id: event.id,
aggregate_id: aggregateID, aggregate_id: aggregateID,
seq, seq,
type: versionedType(definition.type, sync.version), type: versionedType(definition.type, durable.version),
data: encoded, data: encoded,
}, },
]) ])
@ -369,8 +344,8 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (committed) { if (committed) {
yield* Effect.forEach( yield* Effect.forEach(
synchronized.get(committed.aggregateID) ?? [], pubsub.durable.get(committed.aggregateID) ?? [],
(pubsub) => PubSub.publish(pubsub, undefined), (wake) => PubSub.publish(wake, undefined),
{ discard: true }, { discard: true },
) )
} }
@ -384,19 +359,25 @@ export const layerWith = (options?: LayerOptions) =>
function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) { function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
return Effect.gen(function* () { return Effect.gen(function* () {
const durable = registry.get(event.type)?.sync !== undefined const definition = registry.get(event.type)
if (!durable && commit) if (!definition?.durable && commit)
return yield* Effect.die( return yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: event.type, type: event.type,
message: "Local commit hooks require a synchronized event", message: "Local commit hooks require a durable event",
}), }),
) )
if (durable) { if (definition?.durable) {
const committed = yield* commitSyncEvent(event as Payload, undefined, commit) const committed = yield* commitDurableEvent(event as Payload, undefined, commit)
if (committed) { if (committed) {
event = { ...event, seq: committed.seq } event = {
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true }) ...event,
durable: {
aggregateID: committed.aggregateID,
seq: committed.seq,
version: definition.durable.version,
},
}
yield* notify(event as Payload, true) yield* notify(event as Payload, true)
return event return event
} }
@ -406,12 +387,11 @@ export const layerWith = (options?: LayerOptions) =>
}) })
} }
const observe = (event: Payload, kind: "sync" | "listener", observer: (event: Payload) => Effect.Effect<void>) => const observe = (event: Payload, observer: (event: Payload) => Effect.Effect<void>) =>
Effect.suspend(() => observer(event)).pipe( Effect.suspend(() => observer(event)).pipe(
Effect.catchCauseIf( Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause), (cause) => !Cause.hasInterrupts(cause),
(cause) => (cause) => Effect.logError("Event listener failed", { eventID: event.id, eventType: event.type, cause }),
Effect.logError("Event observer failed", { eventID: event.id, eventType: event.type, kind, cause }),
), ),
) )
@ -419,12 +399,12 @@ export const layerWith = (options?: LayerOptions) =>
return Effect.gen(function* () { return Effect.gen(function* () {
yield* Effect.forEach( yield* Effect.forEach(
listeners, listeners,
(listener) => (isolateListeners ? observe(event, "listener", listener) : listener(event)), (listener) => (isolateListeners ? observe(event, listener) : listener(event)),
{ discard: true }, { discard: true },
) )
const pubsub = typed.get(event.type) const typed = pubsub.typed.get(event.type)
if (pubsub) yield* PubSub.publish(pubsub, event) if (typed) yield* PubSub.publish(typed, event)
yield* PubSub.publish(all, event) yield* PubSub.publish(pubsub.all, event)
}) })
} }
@ -441,7 +421,6 @@ export const layerWith = (options?: LayerOptions) =>
id: options?.id ?? ID.create(), id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}), ...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type, type: definition.type,
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
...(location ? { location } : {}), ...(location ? { location } : {}),
data, data,
} as Payload<D>, } as Payload<D>,
@ -455,27 +434,37 @@ export const layerWith = (options?: LayerOptions) =>
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
) { ) {
return Effect.gen(function* () { return Effect.gen(function* () {
const definition = syncRegistry.get(event.type) const definition = durableRegistry.get(event.type)
if (!definition) { if (!definition?.durable) {
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }), new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
) )
} else { } else {
const payload = { const payload = {
id: event.id, id: event.id,
type: definition.type, type: definition.type,
version: definition.sync.version, data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(
data: definition.decode(event.data), event.data,
replay: true, ),
} as Payload } as Payload
const committed = yield* commitSyncEvent(payload, { const committed = yield* commitDurableEvent(payload, {
seq: event.seq, seq: event.seq,
aggregateID: event.aggregateID, aggregateID: event.aggregateID,
ownerID: options?.ownerID, ownerID: options?.ownerID,
strictOwner: options?.strictOwner, strictOwner: options?.strictOwner,
}) })
if (committed && options?.publish) { if (committed && options?.publish) {
yield* notify({ ...payload, seq: committed.seq }, true) yield* notify(
{
...payload,
durable: {
aggregateID: committed.aggregateID,
seq: committed.seq,
version: definition.durable.version,
},
},
true,
)
} }
} }
}) })
@ -490,7 +479,7 @@ export const layerWith = (options?: LayerOptions) =>
if (!source) return undefined if (!source) return undefined
if (events.some((event) => event.aggregateID !== source)) { if (events.some((event) => event.aggregateID !== source)) {
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: events[0]?.type ?? "unknown", type: events[0]?.type ?? "unknown",
message: "Replay events must belong to the same aggregate", message: "Replay events must belong to the same aggregate",
}), }),
@ -501,7 +490,7 @@ export const layerWith = (options?: LayerOptions) =>
const seq = start + index const seq = start + index
if (event.seq !== seq) { if (event.seq !== seq) {
yield* Effect.die( yield* Effect.die(
new InvalidSyncEventError({ new InvalidDurableEventError({
type: event.type, type: event.type,
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`, message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
}), }),
@ -540,22 +529,18 @@ export const layerWith = (options?: LayerOptions) =>
Stream.map((event) => event as Payload<D>), Stream.map((event) => event as Payload<D>),
) )
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all) const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => { const decodeSerializedEvent = (event: SerializedEvent): Payload => {
const definition = syncRegistry.get(event.type) const definition = durableRegistry.get(event.type)
if (!definition) { if (!definition?.durable) {
throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }) throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
} }
return { return {
cursor: Cursor.make(event.seq), id: event.id,
event: { type: definition.type,
id: event.id, durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
type: definition.type, data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(event.data),
version: definition.sync.version,
seq: event.seq,
data: definition.decode(event.data),
},
} }
} }
@ -583,43 +568,40 @@ export const layerWith = (options?: LayerOptions) =>
), ),
) )
const subscribeSynchronized = (aggregateID: string) => const subscribeDurable = (aggregateID: string) =>
Effect.gen(function* () { Effect.gen(function* () {
const pubsub = yield* PubSub.sliding<void>(1) const wake = yield* PubSub.sliding<void>(1)
const subscription = yield* PubSub.subscribe(pubsub) const subscription = yield* PubSub.subscribe(wake)
yield* Effect.acquireRelease( yield* Effect.acquireRelease(
Effect.sync(() => { Effect.sync(() => {
const pubsubs = synchronized.get(aggregateID) ?? new Set() const wakes = pubsub.durable.get(aggregateID) ?? new Set()
pubsubs.add(pubsub) wakes.add(wake)
synchronized.set(aggregateID, pubsubs) pubsub.durable.set(aggregateID, wakes)
}), }),
() => () =>
Effect.sync(() => { Effect.sync(() => {
const pubsubs = synchronized.get(aggregateID) const wakes = pubsub.durable.get(aggregateID)
pubsubs?.delete(pubsub) wakes?.delete(wake)
if (pubsubs?.size === 0) synchronized.delete(aggregateID) if (wakes?.size === 0) pubsub.durable.delete(aggregateID)
}).pipe(Effect.andThen(PubSub.shutdown(pubsub))), }).pipe(Effect.andThen(PubSub.shutdown(wake))),
) )
return subscription return subscription
}) })
const streamEvents = (input: { const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream<Payload> =>
readonly aggregateID: string
readonly after?: Cursor
}): Stream.Stream<CursorEvent> =>
Stream.unwrap( Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
const synchronized = yield* subscribeSynchronized(input.aggregateID) const wakes = yield* subscribeDurable(input.aggregateID)
let cursor = input.after ?? -1 let sequence = input.after ?? -1
const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe( const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe(
Effect.tap((events) => Effect.tap((events) =>
Effect.sync(() => { Effect.sync(() => {
cursor = events.at(-1)?.cursor ?? cursor sequence = events.at(-1)?.durable?.seq ?? sequence
}), }),
), ),
) )
const historical = yield* read const historical = yield* read
const live = Stream.fromSubscription(synchronized).pipe( const live = Stream.fromSubscription(wakes).pipe(
Stream.mapEffect(() => read), Stream.mapEffect(() => read),
Stream.flattenIterable, Stream.flattenIterable,
) )
@ -627,7 +609,7 @@ export const layerWith = (options?: LayerOptions) =>
}), }),
) )
const listen = (listener: Listener): Effect.Effect<Unsubscribe> => const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
Effect.sync(() => { Effect.sync(() => {
listeners.push(listener) listeners.push(listener)
return Effect.sync(() => { return Effect.sync(() => {
@ -636,21 +618,7 @@ export const layerWith = (options?: LayerOptions) =>
}) })
}) })
const sync = (handler: Sync): Effect.Effect<Unsubscribe> => const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
Effect.sync(() => {
syncHandlers.push(handler)
return Effect.sync(() => {
const index = syncHandlers.indexOf(handler)
if (index >= 0) syncHandlers.splice(index, 1)
})
})
const beforeCommit = (guard: CommitGuard): Effect.Effect<void> =>
Effect.sync(() => {
commitGuards.push(guard)
})
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
Effect.sync(() => { Effect.sync(() => {
const list = projectors.get(definition.type) ?? [] const list = projectors.get(definition.type) ?? []
list.push((event) => projector(event as Payload<D>)) list.push((event) => projector(event as Payload<D>))
@ -661,10 +629,8 @@ export const layerWith = (options?: LayerOptions) =>
publish, publish,
subscribe, subscribe,
all: streamAll, all: streamAll,
aggregateEvents: streamEvents, durable,
sync,
listen, listen,
beforeCommit,
project, project,
replay, replay,
replayAll, replayAll,

View file

@ -123,6 +123,6 @@ const baseLayer = Layer.effect(
}), }),
) )
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer)) export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
export const locationLayer = layer export const locationLayer = layer

View file

@ -232,6 +232,6 @@ export const fffLayer = Layer.effect(
}), }),
) )
export const defaultLayer = Layer.unwrap( export const locationLayer = Layer.unwrap(
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)), Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
) )

View file

@ -13,7 +13,7 @@ import { filesystem } from "./effect/layer-node-platform"
export namespace FSUtil { export namespace FSUtil {
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", { export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
method: Schema.String, method: Schema.String,
cause: Schema.optional(Schema.Defect), cause: Schema.optional(Schema.Defect()),
}) {} }) {}
export type Error = PlatformError | FileSystemError export type Error = PlatformError | FileSystemError

View file

@ -32,14 +32,14 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
message: Schema.String, message: Schema.String,
directory: Schema.optional(AbsolutePath), directory: Schema.optional(AbsolutePath),
forceRequired: Schema.optional(Schema.Boolean), forceRequired: Schema.optional(Schema.Boolean),
cause: Schema.optional(Schema.Defect), cause: Schema.optional(Schema.Defect()),
}) {} }) {}
export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.PatchError", { export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.PatchError", {
operation: Schema.Literals(["capture", "apply", "reset"]), operation: Schema.Literals(["capture", "apply", "reset"]),
directory: AbsolutePath, directory: AbsolutePath,
message: Schema.String, message: Schema.String,
cause: Schema.optional(Schema.Defect), cause: Schema.optional(Schema.Defect()),
}) {} }) {}
export interface Interface { export interface Interface {

View file

@ -1,7 +1,19 @@
export * as Integration from "./integration" export * as Integration from "./integration"
import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Schedule, Schema, Scope, SynchronizedRef } from "effect" import {
import { castDraft, enableMapSet, type Draft } from "immer" Cause,
Clock,
Context,
Duration,
Effect,
Exit,
Layer,
Schedule,
Schema,
Scope,
SynchronizedRef,
Types,
} from "effect"
import { Credential } from "./credential" import { Credential } from "./credential"
import { IntegrationSchema } from "./integration/schema" import { IntegrationSchema } from "./integration/schema"
import { withStatics } from "./schema" import { withStatics } from "./schema"
@ -42,12 +54,14 @@ export const SelectPrompt = Schema.Struct({
type: Schema.Literal("select"), type: Schema.Literal("select"),
key: Schema.String, key: Schema.String,
message: Schema.String, message: Schema.String,
options: Schema.Array( options: Schema.mutable(
Schema.Struct({ Schema.Array(
label: Schema.String, Schema.Struct({
value: Schema.String, label: Schema.String,
hint: Schema.optional(Schema.String), value: Schema.String,
}), hint: Schema.optional(Schema.String),
}),
),
), ),
when: Schema.optional(When), when: Schema.optional(When),
}).annotate({ identifier: "Integration.SelectPrompt" }) }).annotate({ identifier: "Integration.SelectPrompt" })
@ -60,7 +74,7 @@ export const OAuthMethod = Schema.Struct({
id: MethodID, id: MethodID,
type: Schema.Literal("oauth"), type: Schema.Literal("oauth"),
label: Schema.String, label: Schema.String,
prompts: Schema.optional(Schema.Array(Prompt)), prompts: Schema.optional(Schema.mutable(Schema.Array(Prompt))),
}).annotate({ identifier: "Integration.OAuthMethod" }) }).annotate({ identifier: "Integration.OAuthMethod" })
export type OAuthMethod = typeof OAuthMethod.Type export type OAuthMethod = typeof OAuthMethod.Type
@ -72,7 +86,7 @@ export type KeyMethod = typeof KeyMethod.Type
export const EnvMethod = Schema.Struct({ export const EnvMethod = Schema.Struct({
type: Schema.Literal("env"), type: Schema.Literal("env"),
names: Schema.Array(Schema.String), names: Schema.mutable(Schema.Array(Schema.String)),
}).annotate({ identifier: "Integration.EnvMethod" }) }).annotate({ identifier: "Integration.EnvMethod" })
export type EnvMethod = typeof EnvMethod.Type export type EnvMethod = typeof EnvMethod.Type
@ -82,8 +96,8 @@ export type Method = typeof Method.Type
export class Info extends Schema.Class<Info>("Integration.Info")({ export class Info extends Schema.Class<Info>("Integration.Info")({
id: ID, id: ID,
name: Schema.String, name: Schema.String,
methods: Schema.Array(Method), methods: Schema.mutable(Schema.Array(Method)),
connections: Schema.Array(IntegrationConnection.Info), connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)),
}) {} }) {}
export type Inputs = Readonly<{ [key: string]: string }> export type Inputs = Readonly<{ [key: string]: string }>
@ -94,11 +108,11 @@ export type OAuthAuthorization = {
} & ( } & (
| { | {
readonly mode: "auto" readonly mode: "auto"
readonly callback: Effect.Effect<Credential.Info, unknown> readonly callback: Effect.Effect<Credential.Value, unknown>
} }
| { | {
readonly mode: "code" readonly mode: "code"
readonly callback: (code: string) => Effect.Effect<Credential.Info, unknown> readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
} }
) )
@ -154,7 +168,7 @@ export class CodeRequiredError extends Schema.TaggedErrorClass<CodeRequiredError
}) {} }) {}
export class AuthorizationError extends Schema.TaggedErrorClass<AuthorizationError>()("Integration.Authorization", { export class AuthorizationError extends Schema.TaggedErrorClass<AuthorizationError>()("Integration.Authorization", {
cause: Schema.Defect, cause: Schema.Defect(),
}) {} }) {}
export type Error = CodeRequiredError | AuthorizationError export type Error = CodeRequiredError | AuthorizationError
@ -172,19 +186,19 @@ export type Ref = {
} }
type Entry = { type Entry = {
ref: Ref ref: Types.DeepMutable<Ref>
methods: Method[] methods: Types.DeepMutable<Method>[]
implementations: Map<MethodID, OAuthImplementation> implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
} }
type Data = { type Data = {
integrations: Map<ID, Entry> integrations: Map<ID, Entry>
} }
export type Editor = { export type Draft = {
list: () => readonly Ref[] list: () => readonly Ref[]
get: (id: ID) => Ref | undefined get: (id: ID) => Ref | undefined
update: (id: ID, update: (integration: Draft<Ref>) => void) => void update: (id: ID, update: (integration: Types.DeepMutable<Ref>) => void) => void
remove: (id: ID) => void remove: (id: ID) => void
method: { method: {
list: (integrationID: ID) => readonly Method[] list: (integrationID: ID) => readonly Method[]
@ -193,18 +207,13 @@ export type Editor = {
} }
} }
export interface Interface { export interface Interface extends State.Transformable<Draft> {
/** Registers a scoped transform over the integration registry. */ /** Registers a scoped transform over the integration registry. */
readonly transform: State.Interface<Data, Editor>["transform"]
/** Registers and immediately applies a scoped integration registry update. */
readonly update: State.Interface<Data, Editor>["update"]
/** Returns one integration with its methods and current connections. */ /** Returns one integration with its methods and current connections. */
readonly get: (id: ID) => Effect.Effect<Info | undefined> readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Returns all integrations with their methods and current connections. */ /** Returns all integrations with their methods and current connections. */
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
readonly connection: { readonly connection: {
/** Returns active connections for every registered or credential-backed integration. */
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
/** Returns the active connection for one integration. */ /** Returns the active connection for one integration. */
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined> readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
/** Runs a key method and stores the resulting credential. */ /** Runs a key method and stores the resulting credential. */
@ -230,7 +239,7 @@ export interface Interface {
/** Updates a stored credential exposed as a connection. */ /** Updates a stored credential exposed as a connection. */
readonly update: ( readonly update: (
credentialID: Credential.ID, credentialID: Credential.ID,
updates: Partial<Pick<Credential.Stored, "label">>, updates: Partial<Pick<Credential.Info, "label">>,
) => Effect.Effect<void> ) => Effect.Effect<void>
/** Removes a stored credential connection. */ /** Removes a stored credential connection. */
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void> readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
@ -252,8 +261,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
enableMapSet()
const attemptLifetime = Duration.toMillis(Duration.minutes(10)) const attemptLifetime = Duration.toMillis(Duration.minutes(10))
const terminalRetention = Duration.toMillis(Duration.minutes(1)) const terminalRetention = Duration.toMillis(Duration.minutes(1))
const scrubInterval = Duration.seconds(30) const scrubInterval = Duration.seconds(30)
@ -284,15 +291,17 @@ export const locationLayer = Layer.effect(
const events = yield* EventV2.Service const events = yield* EventV2.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>()) const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
const state = State.create<Data, Editor>({ const state = State.create<Data, Draft>({
initial: () => ({ integrations: new Map<ID, Entry>() }), initial: () => ({ integrations: new Map<ID, Entry>() }),
editor: (draft) => ({ draft: (draft) => ({
list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[], list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[],
get: (id) => draft.integrations.get(id)?.ref as Ref | undefined, get: (id) => draft.integrations.get(id)?.ref as Ref | undefined,
update: (id, update) => { update: (id, update) => {
const current = const current = draft.integrations.get(id) ?? {
draft.integrations.get(id) ?? ref: { id, name: id },
castDraft({ ref: { id, name: id } as Ref, methods: [], implementations: new Map() }) methods: [],
implementations: new Map(),
}
if (!draft.integrations.has(id)) draft.integrations.set(id, current) if (!draft.integrations.has(id)) draft.integrations.set(id, current)
update(current.ref) update(current.ref)
current.ref.id = id current.ref.id = id
@ -301,16 +310,14 @@ export const locationLayer = Layer.effect(
method: { method: {
list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [], list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [],
update: (implementation) => { update: (implementation) => {
const current = const current = draft.integrations.get(implementation.integrationID) ?? {
draft.integrations.get(implementation.integrationID) ?? ref: {
castDraft({ id: implementation.integrationID,
ref: { name: implementation.integrationID,
id: implementation.integrationID, },
name: implementation.integrationID, methods: [],
} as Ref, implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
methods: [], }
implementations: new Map<MethodID, OAuthImplementation>(),
})
if (!draft.integrations.has(implementation.integrationID)) { if (!draft.integrations.has(implementation.integrationID)) {
draft.integrations.set(implementation.integrationID, current) draft.integrations.set(implementation.integrationID, current)
} }
@ -319,10 +326,13 @@ export const locationLayer = Layer.effect(
if (method.type !== "oauth" || implementation.method.type !== "oauth") return true if (method.type !== "oauth" || implementation.method.type !== "oauth") return true
return method.id === implementation.method.id return method.id === implementation.method.id
}) })
if (index === -1) current.methods.push(castDraft(implementation.method)) if (index === -1) current.methods.push(implementation.method as Types.DeepMutable<Method>)
else current.methods[index] = castDraft(implementation.method) else current.methods[index] = implementation.method as Types.DeepMutable<Method>
if (isOAuthImplementation(implementation)) { if (implementation.method.type === "oauth") {
current.implementations.set(implementation.method.id, castDraft(implementation)) current.implementations.set(
implementation.method.id,
implementation as Types.DeepMutable<OAuthImplementation>,
)
} }
}, },
remove: (integrationID, method) => { remove: (integrationID, method) => {
@ -341,39 +351,27 @@ export const locationLayer = Layer.effect(
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
}) })
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => { const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
const connected = saved.map((credential) => ({ const credentials = saved
type: "credential" as const, .map((credential) => ({
id: credential.id, type: "credential" as const,
label: credential.label, id: credential.id,
})) label: credential.label,
const detected = entry.methods }))
.toReversed()
const env = (entry?.methods ?? [])
.filter((method) => method.type === "env") .filter((method) => method.type === "env")
.flatMap((method) => method.names.filter((name) => process.env[name])) .flatMap((method) => method.names.filter((name) => process.env[name]))
.map((name) => ({ type: "env" as const, name })) .map((name) => ({ type: "env" as const, name }))
return [...connected, ...detected] return [...credentials, ...env]
} }
const activeConnection = ( const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
entry: Entry | undefined,
saved: readonly Credential.Stored[],
): IntegrationConnection.Info | undefined => {
const credential = saved.at(-1)
if (credential) return { type: "credential", id: credential.id, label: credential.label }
if (!entry) return
const name = entry.methods
.filter((method) => method.type === "env")
.flatMap((method) => method.names)
.find((name) => process.env[name])
if (name) return { type: "env", name }
}
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
new Info({ new Info({
id: entry.ref.id, id: entry.ref.id,
name: entry.ref.name, name: entry.ref.name,
methods: entry.methods, methods: entry.methods,
connections: connections(entry, saved), connections,
}) })
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) => const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
@ -387,7 +385,7 @@ export const locationLayer = Layer.effect(
return error instanceof Error ? error.message : String(error) return error instanceof Error ? error.message : String(error)
} }
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Info, unknown>) { const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Value, unknown>) {
const now = yield* Clock.currentTimeMillis const now = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(attempts, (current) => { const result = yield* SynchronizedRef.modify(attempts, (current) => {
const attempt = current.get(attemptID) const attempt = current.get(attemptID)
@ -434,32 +432,22 @@ export const locationLayer = Layer.effect(
return Service.of({ return Service.of({
transform: state.transform, transform: state.transform,
update: state.update, reload: state.reload,
get: Effect.fn("Integration.get")(function* (id) { get: Effect.fn("Integration.get")(function* (id) {
const entry = state.get().integrations.get(id) const entry = state.get().integrations.get(id)
if (!entry) return undefined if (!entry) return undefined
return project(entry, yield* credentials.list(id)) return project(entry, resolveConnections(entry, yield* credentials.list(id)))
}), }),
list: Effect.fn("Integration.list")(function* () { list: Effect.fn("Integration.list")(function* () {
return (yield* Effect.forEach(state.get().integrations.values(), (entry) => const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
Effect.gen(function* () { return Array.from(state.get().integrations.values(), (entry) =>
return project(entry, yield* credentials.list(entry.ref.id)) project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
}), ).toSorted((a, b) => a.name.localeCompare(b.name))
)).toSorted((a, b) => a.name.localeCompare(b.name))
}), }),
connection: { connection: {
list: Effect.fn("Integration.connection.list")(function* () {
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
return new Map(
new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => {
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
return connection ? [[id, connection] as const] : []
}),
)
}),
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) { forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
const entry = state.get().integrations.get(id) const entry = state.get().integrations.get(id)
return activeConnection(entry, yield* credentials.list(id)) return resolveConnections(entry, yield* credentials.list(id))[0]
}), }),
key: Effect.fn("Integration.connection.key")(function* (input) { key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state const method = state

View file

@ -7,7 +7,7 @@ import { Catalog } from "./catalog"
import { Integration } from "./integration" import { Integration } from "./integration"
import { CommandV2 } from "./command" import { CommandV2 } from "./command"
import { AgentV2 } from "./agent" import { AgentV2 } from "./agent"
import { PluginBoot } from "./plugin/boot" import { PluginInternal } from "./plugin/internal"
import { Project } from "./project" import { Project } from "./project"
import { ProjectCopy } from "./project/copy" import { ProjectCopy } from "./project/copy"
import { ProjectDirectories } from "./project/directories" import { ProjectDirectories } from "./project/directories"
@ -65,7 +65,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Integration.locationLayer, Integration.locationLayer,
CommandV2.locationLayer, CommandV2.locationLayer,
AgentV2.locationLayer, AgentV2.locationLayer,
PluginBoot.locationLayer, PluginInternal.locationLayer,
ProjectCopy.locationLayer, ProjectCopy.locationLayer,
FileSystem.locationLayer, FileSystem.locationLayer,
Watcher.locationLayer, Watcher.locationLayer,

View file

@ -33,7 +33,7 @@ export type Request = typeof Request.Type
interface MutableRequest { interface MutableRequest {
headers: Record<string, string> headers: Record<string, string>
body: Record<string, unknown> body: Record<string, unknown>
generation?: Generation generation?: Record<string, unknown>
options?: Record<string, unknown> options?: Record<string, unknown>
} }

View file

@ -1,5 +1,4 @@
import { DateTime, Schema } from "effect" import { Schema, Types } from "effect"
import { DateTimeUtcFromMillis } from "effect/Schema"
import { ProviderV2 } from "./provider" import { ProviderV2 } from "./provider"
import { ModelRequest } from "./model-request" import { ModelRequest } from "./model-request"
@ -16,8 +15,8 @@ export type Family = typeof Family.Type
export const Capabilities = Schema.Struct({ export const Capabilities = Schema.Struct({
tools: Schema.Boolean, tools: Schema.Boolean,
// mime patterns, image, audio, video/*, text/* // mime patterns, image, audio, video/*, text/*
input: Schema.String.pipe(Schema.Array), input: Schema.String.pipe(Schema.Array, Schema.mutable),
output: Schema.String.pipe(Schema.Array), output: Schema.String.pipe(Schema.Array, Schema.mutable),
}) })
export type Capabilities = typeof Capabilities.Type export type Capabilities = typeof Capabilities.Type
@ -67,11 +66,11 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
variants: Schema.Struct({ variants: Schema.Struct({
id: VariantID, id: VariantID,
...ModelRequest.Request.fields, ...ModelRequest.Request.fields,
}).pipe(Schema.Array), }).pipe(Schema.Array, Schema.mutable),
time: Schema.Struct({ time: Schema.Struct({
released: DateTimeUtcFromMillis, released: Schema.Finite,
}), }),
cost: Cost.pipe(Schema.Array), cost: Cost.pipe(Schema.Array, Schema.mutable),
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
enabled: Schema.Boolean, enabled: Schema.Boolean,
limit: Schema.Struct({ limit: Schema.Struct({
@ -103,7 +102,7 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
}, },
variants: [], variants: [],
time: { time: {
released: DateTime.makeUnsafe(0), released: 0,
}, },
cost: [], cost: [],
status: "active", status: "active",
@ -116,6 +115,10 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
} }
} }
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
api: ProviderV2.MutableApi<Api>
}
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
const [providerID, ...modelID] = input.split("/") const [providerID, ...modelID] = input.split("/")
return { return {

View file

@ -15,12 +15,12 @@ import { NpmConfig } from "./npm-config"
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", { export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
add: Schema.Array(Schema.String).pipe(Schema.optional), add: Schema.Array(Schema.String).pipe(Schema.optional),
dir: Schema.String, dir: Schema.String,
cause: Schema.optional(Schema.Defect), cause: Schema.optional(Schema.Defect()),
}) {} }) {}
export interface EntryPoint { export interface EntryPoint {
readonly directory: string readonly directory: string
readonly entrypoint: Option.Option<string> readonly entrypoint?: string
} }
export interface Interface { export interface Interface {
@ -34,7 +34,7 @@ export interface Interface {
}[] }[]
}, },
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError> ) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
readonly which: (pkg: string, bin?: string) => Effect.Effect<Option.Option<string>> readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
@ -47,12 +47,11 @@ export function sanitize(pkg: string) {
} }
const resolveEntryPoint = (name: string, dir: string): EntryPoint => { const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
let entrypoint: Option.Option<string> let entrypoint: string | undefined
try { try {
const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
entrypoint = Option.some(resolved)
} catch { } catch {
entrypoint = Option.none() entrypoint = undefined
} }
return { return {
directory: dir, directory: dir,
@ -130,7 +129,7 @@ export const layer = Layer.effect(
const first = tree.edgesOut.values().next().value?.to const first = tree.edgesOut.values().next().value?.to
if (!first) { if (!first) {
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name)) const result = resolveEntryPoint(name, path.join(dir, "node_modules", name))
if (Option.isSome(result.entrypoint)) return result if (result.entrypoint) return result
return yield* new InstallFailedError({ add: [pkg], dir }) return yield* new InstallFailedError({ add: [pkg], dir })
} }
return resolveEntryPoint(first.name, first.path) return resolveEntryPoint(first.name, first.path)
@ -219,22 +218,24 @@ export const layer = Layer.effect(
return Option.some(files[0]) return Option.some(files[0])
}) })
return yield* Effect.gen(function* () { return Option.getOrUndefined(
const bin = yield* pick() yield* Effect.gen(function* () {
if (Option.isSome(bin)) { const bin = yield* pick()
return Option.some(path.join(binDir, bin.value)) if (Option.isSome(bin)) {
} return Option.some(path.join(binDir, bin.value))
}
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {})) yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
yield* add(pkg) yield* add(pkg)
const resolved = yield* pick() const resolved = yield* pick()
if (Option.isNone(resolved)) return Option.none<string>() if (Option.isNone(resolved)) return Option.none<string>()
return Option.some(path.join(binDir, resolved.value)) return Option.some(path.join(binDir, resolved.value))
}).pipe( }).pipe(
Effect.scoped, Effect.scoped,
Effect.orElseSucceed(() => Option.none<string>()), Effect.orElseSucceed(() => Option.none<string>()),
),
) )
}) })
@ -261,14 +262,9 @@ export async function install(...args: Parameters<Interface["install"]>) {
} }
export async function add(...args: Parameters<Interface["add"]>) { export async function add(...args: Parameters<Interface["add"]>) {
const entry = await runPromise((svc) => svc.add(...args)) return runPromise((svc) => svc.add(...args))
return {
directory: entry.directory,
entrypoint: Option.getOrUndefined(entry.entrypoint),
}
} }
export async function which(...args: Parameters<Interface["which"]>) { export async function which(...args: Parameters<Interface["which"]>) {
const resolved = await runPromise((svc) => svc.which(...args)) return runPromise((svc) => svc.which(...args))
return Option.getOrUndefined(resolved)
} }

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