Compare commits

..

1 Commits

Author SHA1 Message Date
James Long d1366ed07d test(core): simplify session prompt layer wiring 2026-06-20 22:15:06 -04:00
876 changed files with 18496 additions and 36800 deletions
+1 -1
View File
@@ -2,4 +2,4 @@ blank_issues_enabled: false
contact_links: contact_links:
- name: 💬 Discord Community - name: 💬 Discord Community
url: https://discord.gg/opencode url: https://discord.gg/opencode
about: For support, troubleshooting, how-to questions, and real-time discussion. about: For quick questions or real-time discussion. Note that issues are searchable and help others with the same question.
+10
View File
@@ -0,0 +1,10 @@
name: Question
description: Ask a question
body:
- type: textarea
id: question
attributes:
label: Question
description: What's your question?
validations:
required: true
-1
View File
@@ -325,7 +325,6 @@ jobs:
run: bun run build run: bun run build
working-directory: packages/desktop working-directory: packages/desktop
env: env:
NODE_OPTIONS: --max-old-space-size=4096
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }} SENTRY_ORG: ${{ vars.SENTRY_ORG }}
-2
View File
@@ -9,7 +9,6 @@ on:
- "bun.lock" - "bun.lock"
- "packages/storybook/**" - "packages/storybook/**"
- "packages/ui/**" - "packages/ui/**"
- "packages/session-ui/**"
pull_request: pull_request:
branches: [dev] branches: [dev]
paths: paths:
@@ -18,7 +17,6 @@ on:
- "bun.lock" - "bun.lock"
- "packages/storybook/**" - "packages/storybook/**"
- "packages/ui/**" - "packages/ui/**"
- "packages/session-ui/**"
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
+2 -3
View File
@@ -65,7 +65,7 @@ jobs:
- name: Run unit tests - name: Run unit tests
timeout-minutes: 20 timeout-minutes: 20
run: GITHUB_ACTIONS=false bun turbo test run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
@@ -99,8 +99,7 @@ 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:
# Playwright 1.59 hangs while extracting Chromium with Node 24.16. node-version: "24"
node-version: "24.15"
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
+3 -4
View File
@@ -28,7 +28,6 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity - Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream - Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module. - In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
- In Effect generators, bind services to named variables before calling methods. Do not use nested service yields such as `yield* (yield* Foo.Service).bar()`.
Reduce total variable count by inlining when a value is only used once. Reduce total variable count by inlining when a value is only used once.
@@ -138,7 +137,7 @@ const table = sqliteTable("session", {
## Testing ## Testing
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option. - Avoid mocks as much as possible
- 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`.
@@ -153,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 continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary. - Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work.
- Keep delivery vocabulary explicit. Prompts steer by default and 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 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 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.
+18 -23
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 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. The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
**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,24 +39,19 @@ 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.
**Managed Tool Output File**: **Managed Tool Output File**:
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history. A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
**Model Request Options**:
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
_Avoid_: Request body, wire options
**Generation Controls**:
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
**PTY Environment**: **PTY Environment**:
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory. The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
@@ -72,11 +67,6 @@ 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.
@@ -85,28 +75,33 @@ 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(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable. - `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context.
- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run.
- **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.
- 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. - 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.
- 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. - 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.
- 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.
- 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. - Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn. - A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`. - The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter. - A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
+48 -129
View File
@@ -29,12 +29,11 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.17.10", "version": "1.17.9",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4", "@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:", "@sentry/solid": "catalog:",
@@ -87,7 +86,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@opencode-ai/cli", "name": "@opencode-ai/cli",
"version": "1.17.10", "version": "1.17.9",
"bin": { "bin": {
"lildax": "./bin/lildax.cjs", "lildax": "./bin/lildax.cjs",
}, },
@@ -112,7 +111,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.17.10", "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",
@@ -148,7 +147,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.17.10", "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",
@@ -175,7 +174,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.17.10", "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",
@@ -197,7 +196,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.17.10", "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",
@@ -221,7 +220,7 @@
}, },
"packages/console/support": { "packages/console/support": {
"name": "@opencode-ai/console-support", "name": "@opencode-ai/console-support",
"version": "1.17.10", "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:*",
@@ -241,7 +240,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.17.10", "version": "1.17.9",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -277,8 +276,6 @@
"@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:*",
"@opencode-ai/schema": "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",
@@ -334,7 +331,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.17.10", "version": "1.17.9",
"dependencies": { "dependencies": {
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"effect": "catalog:", "effect": "catalog:",
@@ -388,7 +385,7 @@
}, },
"packages/effect-drizzle-sqlite": { "packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite", "name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.10", "version": "1.17.9",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -402,7 +399,7 @@
}, },
"packages/effect-sqlite-node": { "packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node", "name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.10", "version": "1.17.9",
"dependencies": { "dependencies": {
"effect": "catalog:", "effect": "catalog:",
}, },
@@ -414,11 +411,10 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.17.10", "version": "1.17.9",
"dependencies": { "dependencies": {
"@hono/standard-validator": "catalog:", "@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
@@ -446,7 +442,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.17.10", "version": "1.17.9",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -462,7 +458,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.17.10", "version": "1.17.9",
"dependencies": { "dependencies": {
"@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83",
@@ -481,9 +477,8 @@
}, },
"packages/llm": { "packages/llm": {
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"version": "1.17.10", "version": "1.17.9",
"dependencies": { "dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14", "@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2", "@smithy/util-utf8": "4.2.2",
"aws4fetch": "1.0.20", "aws4fetch": "1.0.20",
@@ -500,7 +495,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.17.10", "version": "1.17.9",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -539,8 +534,6 @@
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*", "@opencode-ai/server": "workspace:*",
@@ -630,9 +623,8 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.17.10", "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:",
@@ -647,9 +639,9 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
"peerDependencies": { "peerDependencies": {
"@opentui/core": ">=0.4.2", "@opentui/core": ">=0.3.4",
"@opentui/keymap": ">=0.4.2", "@opentui/keymap": ">=0.3.4",
"@opentui/solid": ">=0.4.2", "@opentui/solid": ">=0.3.4",
}, },
"optionalPeers": [ "optionalPeers": [
"@opentui/core", "@opentui/core",
@@ -657,29 +649,6 @@
"@opentui/solid", "@opentui/solid",
], ],
}, },
"packages/protocol": {
"name": "@opencode-ai/protocol",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/schema": {
"name": "@opencode-ai/schema",
"dependencies": {
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/script": { "packages/script": {
"name": "@opencode-ai/script", "name": "@opencode-ai/script",
"dependencies": { "dependencies": {
@@ -692,7 +661,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.17.10", "version": "1.17.9",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -707,10 +676,9 @@
}, },
"packages/server": { "packages/server": {
"name": "@opencode-ai/server", "name": "@opencode-ai/server",
"version": "1.17.10", "version": "1.17.9",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
}, },
@@ -720,53 +688,9 @@
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
}, },
}, },
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.17.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"diff": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:",
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"morphdom": "2.7.8",
"motion": "12.34.5",
"remeda": "catalog:",
"remend": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
"solid-list": "catalog:",
"strip-ansi": "7.1.2",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/katex": "0.16.7",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
},
},
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.17.10", "version": "1.17.9",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -779,7 +703,7 @@
}, },
"packages/stats/app": { "packages/stats/app": {
"name": "@opencode-ai/stats-app", "name": "@opencode-ai/stats-app",
"version": "1.17.10", "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:*",
@@ -812,7 +736,7 @@
}, },
"packages/stats/core": { "packages/stats/core": {
"name": "@opencode-ai/stats-core", "name": "@opencode-ai/stats-core",
"version": "1.17.10", "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",
@@ -831,7 +755,7 @@
}, },
"packages/stats/server": { "packages/stats/server": {
"name": "@opencode-ai/stats-server", "name": "@opencode-ai/stats-server",
"version": "1.17.10", "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:",
@@ -850,7 +774,6 @@
"packages/storybook": { "packages/storybook": {
"name": "@opencode-ai/storybook", "name": "@opencode-ai/storybook",
"devDependencies": { "devDependencies": {
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@storybook/addon-a11y": "^10.2.13", "@storybook/addon-a11y": "^10.2.13",
@@ -872,7 +795,7 @@
}, },
"packages/tui": { "packages/tui": {
"name": "@opencode-ai/tui", "name": "@opencode-ai/tui",
"version": "1.17.10", "version": "1.17.9",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
@@ -899,9 +822,11 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.17.10", "version": "1.17.9",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:", "@shikijs/stream": "catalog:",
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
@@ -946,7 +871,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.17.10", "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",
@@ -1020,9 +945,9 @@
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.4.2", "@opentui/core": "0.3.4",
"@opentui/keymap": "0.4.2", "@opentui/keymap": "0.3.4",
"@opentui/solid": "0.4.2", "@opentui/solid": "0.3.4",
"@pierre/diffs": "1.2.10", "@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0", "@sentry/solid": "10.36.0",
@@ -1877,18 +1802,12 @@
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"], "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"], "@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"], "@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
"@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"], "@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"],
@@ -1933,27 +1852,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
"@opentui/core": ["@opentui/core@0.4.2", "", { "dependencies": { "bun-ffi-structs": "0.2.3", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.2", "@opentui/core-darwin-x64": "0.4.2", "@opentui/core-linux-arm64": "0.4.2", "@opentui/core-linux-arm64-musl": "0.4.2", "@opentui/core-linux-x64": "0.4.2", "@opentui/core-linux-x64-musl": "0.4.2", "@opentui/core-win32-arm64": "0.4.2", "@opentui/core-win32-x64": "0.4.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ulx6RMqftf2fm7Itf9e81GcCDMNY6NAhmnKYhllDOMYD+PxYXR+vomy2bxQNV5ow31RE7s8WQFnb7hWTRUbx2g=="], "@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-is+O+sS/l3E9cZXyM9pRF1WhqnE+hYSPYoZkbseR9CthJcaWPGi3R3jUJa1cLj325252jWgxVupnDqFUtKg36w=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ACi42h81DurSeybUAD1XyKT6xmXZcKeTxS54lZFi0CVZh46w0g99vNj8PlQzIFXvvFLT0e0IlRS//eWSWS2zGQ=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-RjOx2HcjLRtGSy9WrAGSdr5M9SpJuPifPORpImx6Mciovw0ltnE0uoYjIyor82uf6/LExWC7YA2AcAl+YBxayA=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-heNciL2ngPU+kq1h01PHLsxn6Fr8iqTFtbxSdVbhaY3XihuIjkuXyEhFeuoa1lsXY7Bb2gpWnX5EQVWnZsAuDQ=="], "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9s0s/ooK+AhWP306By3gu+XhzcVEThC2sqKMPK1nQmGDujQhd+xOrtbtfCVcJSx62UzAovC2VNqypvP8vHByOg=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cjv6Bv7l3p/KLNJr5RyqCS0FmRlAGJnkA2IK3S+HkHhCOv/O02S1G+DBUY6POnyjp1eNy95vauustApobhdbig=="], "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mfJZrJ0TNPFRZUzXNsxAPe1YdiWsy/vbTl93+yeXGHPI1B8Qnk9V5hpzSxxEyBGhlTHSfGNtgiO+VrrdRC3kZA=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-P2oguG3ng3OMjAdasFSA3GhHaQXtzDUsIRDGbzWFOimpZ/zMemidp+JQ0V8V6XwK6Utk5G0aQ03oBaRCoLyYDw=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="],
"@opentui/keymap": ["@opentui/keymap@0.4.2", "", { "dependencies": { "@opentui/core": "0.4.2" }, "peerDependencies": { "@opentui/react": "0.4.2", "@opentui/solid": "0.4.2", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wxBEFfWgm3feqCRLckWg1JH4tbMJinpyK3yobkLTsWJ7PDsM+fPoFMyQ8ieKVdUL2eP6ELTmHvM1bHKShZ7SUQ=="], "@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="],
"@opentui/solid": ["@opentui/solid@0.4.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zuYXsnrlsMtnXrS7QCYBdPzMtUSonG2LqnJikBR2NjEE2O4zEKvJd48n3eB1igcxjv96tiotTXRNCylYS0SNdQ=="], "@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -3131,7 +3050,7 @@
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.3", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-pgJiXP+hEgFo9qG51J6ItfY4ocs3vniwNzJ9WhoakB3QB2GdzQxX2EXssentPYlB2hOfJrTjO6iIQkWYzUodpg=="], "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
@@ -5489,6 +5408,8 @@
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
@@ -5907,8 +5828,6 @@
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], "@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
-1
View File
@@ -256,7 +256,6 @@ new sst.cloudflare.x.SolidStart("Console", {
SECRET.UpstashRedisRestToken, SECRET.UpstashRedisRestToken,
AUTH_API_URL, AUTH_API_URL,
STRIPE_WEBHOOK_SECRET, STRIPE_WEBHOOK_SECRET,
SECRET.SupportApiKey,
DISCORD_INCIDENT_WEBHOOK_URL, DISCORD_INCIDENT_WEBHOOK_URL,
SECRET.HoneycombWebhookSecret, SECRET.HoneycombWebhookSecret,
STRIPE_SECRET_KEY, STRIPE_SECRET_KEY,
-1
View File
@@ -7,7 +7,6 @@ new sst.cloudflare.x.SolidStart("Teams", {
domain: shortDomain, domain: shortDomain,
path: "packages/enterprise", path: "packages/enterprise",
buildCommand: "bun run build:cloudflare", buildCommand: "bun run build:cloudflare",
link: [SECRET.SupportApiKey],
environment: { environment: {
OPENCODE_STORAGE_ADAPTER: "r2", OPENCODE_STORAGE_ADAPTER: "r2",
OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID, OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID,
-1
View File
@@ -9,7 +9,6 @@ export const SECRET = {
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"), R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"), HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }), HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
SupportApiKey: new sst.Secret("SUPPORT_API_KEY"),
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"), UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"), UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
} }
+2 -4
View File
@@ -42,12 +42,11 @@ const inferenceEventTable = new aws.s3tables.Table(
{ name: "request", type: "string", required: false }, { name: "request", type: "string", required: false },
{ name: "client", type: "string", required: false }, { name: "client", type: "string", required: false },
{ name: "user_agent", type: "string", required: false }, { name: "user_agent", type: "string", required: false },
{ name: "model", type: "string", required: false },
{ name: "model_tier", type: "string", required: false },
{ name: "model_variant", type: "string", required: false }, { name: "model_variant", type: "string", required: false },
{ name: "source", type: "string", required: false }, { name: "source", type: "string", required: false },
{ name: "provider", type: "string", required: false }, { name: "provider", type: "string", required: false },
{ name: "provider_model", type: "string", required: false }, { name: "provider_model", type: "string", required: false },
{ name: "model", type: "string", required: false },
{ name: "llm_error_code", type: "int", required: false }, { name: "llm_error_code", type: "int", required: false },
{ name: "llm_error_message", type: "string", required: false }, { name: "llm_error_message", type: "string", required: false },
{ name: "error_response", type: "string", required: false }, { name: "error_response", type: "string", required: false },
@@ -57,7 +56,6 @@ const inferenceEventTable = new aws.s3tables.Table(
{ name: "error_cause2", type: "string", required: false }, { name: "error_cause2", type: "string", required: false },
{ name: "api_key", type: "string", required: false }, { name: "api_key", type: "string", required: false },
{ name: "workspace", type: "string", required: false }, { name: "workspace", type: "string", required: false },
{ name: "user_id", type: "string", required: false },
{ name: "is_subscription", type: "boolean", required: false }, { name: "is_subscription", type: "boolean", required: false },
{ name: "subscription", type: "string", required: false }, { name: "subscription", type: "string", required: false },
{ name: "response_length", type: "long", required: false }, { name: "response_length", type: "long", required: false },
@@ -86,7 +84,7 @@ const inferenceEventTable = new aws.s3tables.Table(
}, },
}, },
}, },
{ deleteBeforeReplace: $app.stage !== "production", ignoreChanges: ["metadata"] }, { deleteBeforeReplace: $app.stage !== "production" },
) )
export const inferenceEvent = new sst.Linkable("InferenceEvent", { export const inferenceEvent = new sst.Linkable("InferenceEvent", {
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-Q0CW2wMhVtRM3uILV1kvKAzpQJqBxTp4MLsGlsSoT1s=", "x86_64-linux": "sha256-g0tDvRf7MErZ1PEeUazEYi492ZHiRT8kYv3bPdkss/I=",
"aarch64-linux": "sha256-UHpoDqqV+umG47LENB7DLxbCqvwjTCDjHWIFiv6Vmk8=", "aarch64-linux": "sha256-6sKgf3ftbIqlPxlFkoPzoWPsJp3IwXD+H3Y6g874xmk=",
"aarch64-darwin": "sha256-rqlFositEiB1wzx6Jcp5yVQlp8LLYBW+79KXmRpXh2Q=", "aarch64-darwin": "sha256-Se/Nls/KlkuK2ysDQ9DeAzSaX3NsL2iDdf/dsv2GIXc=",
"x86_64-darwin": "sha256-BKr4YQio3eP0VLZ0z+oyaM93H8dAbSwmlzxT8PH6W8M=" "x86_64-darwin": "sha256-V9MCkqnvQ1nkD2PaaTfNFKkBZGymj6KxrSAK6+DTF8Y="
} }
} }
-7
View File
@@ -27,13 +27,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
writableTmpDirAsHomeHook writableTmpDirAsHomeHook
]; ];
postPatch = ''
# NOTE: Relax Bun version check to be a warning instead of an error
substituteInPlace packages/script/src/index.ts \
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
'';
configurePhase = '' configurePhase = ''
runHook preConfigure runHook preConfigure
+3 -3
View File
@@ -39,9 +39,9 @@
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0", "@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.4.2", "@opentui/core": "0.3.4",
"@opentui/keymap": "0.4.2", "@opentui/keymap": "0.3.4",
"@opentui/solid": "0.4.2", "@opentui/solid": "0.3.4",
"@tanstack/solid-virtual": "3.13.28", "@tanstack/solid-virtual": "3.13.28",
"@shikijs/stream": "4.2.0", "@shikijs/stream": "4.2.0",
"ulid": "3.0.1", "ulid": "3.0.1",
-2
View File
@@ -18,8 +18,6 @@ bun run test:bench
The suite contains: The suite contains:
- cold and hot session-tab timing - cold and hot session-tab timing
- home-session click timing split between content and titlebar-tab paint
- single-session tab close timing through stable home restoration
- cached session repaint and mutation tracing - cached session repaint and mutation tracing
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics - streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
@@ -1,87 +0,0 @@
import { expectSessionTitle } from "../../utils/waits"
import { benchmark, expect } from "../benchmark"
import { measureFirstNavigation } from "./first-navigation-probe"
import { fixture } from "./session-timeline-stress.fixture"
import {
installStressSessionTabs,
installTimelineSettings,
mockStressTimeline,
stressDraftHref,
stressSessionHref,
} from "./timeline-test-helpers"
import { waitForStableTimeline } from "./session-tab-switch-probe"
const contentSelector = '[data-message-id], [data-component="prompt-input"]'
const draftID = "draft_first_navigation"
benchmark.describe("performance: first navigation paint", () => {
benchmark("opens an unvisited session tab without a blank frame", async ({ page, report }) => {
await setup(page)
const href = stressSessionHref(fixture.targetID)
const result = await measureFirstNavigation(page, {
href,
destinationPath: href,
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
contentSelector,
navigate: async () => {
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
await expectSessionTitle(page, fixture.expected.targetTitle)
},
})
report(result)
expect(result.summary.blankSamples).toBe(0)
expect(result.summary.unknownSamples).toBe(0)
})
benchmark("opens the new session page before its lazy module is used", async ({ page, report }) => {
await setup(page, draftID)
const href = stressDraftHref(draftID)
const result = await measureFirstNavigation(page, {
href,
destinationPath: href,
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
destinationSelector: '[data-component="prompt-input"]',
contentSelector,
navigate: async () => {
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
},
})
report(result)
expect(result.summary.blankSamples).toBe(0)
expect(result.summary.unknownSamples).toBe(0)
})
benchmark("opens a child session without a blank frame", async ({ page, report }) => {
await setup(page)
const href = stressSessionHref(fixture.childID)
const result = await measureFirstNavigation(page, {
href,
destinationPath: href,
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
destinationSelector: messageSelector(fixture.expected.childMessageIDs.at(-1)!),
contentSelector,
navigate: async () => {
await page.locator(`a[href="${href}"]`, { has: page.locator('[data-component="task-tool-card"]') }).click()
await expectSessionTitle(page, fixture.expected.childTitle)
},
})
report(result)
expect(result.summary.blankSamples).toBe(0)
expect(result.summary.unknownSamples).toBe(0)
})
})
async function setup(page: Parameters<typeof mockStressTimeline>[0], draft?: string) {
await mockStressTimeline(page)
await installTimelineSettings(page)
await installStressSessionTabs(page, draft ? { draftID: draft } : undefined)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
}
function messageSelector(id: string) {
return `[data-message-id="${id}"]`
}
@@ -1,32 +0,0 @@
export type FirstNavigationSample = {
observedAtMs: number
source: boolean
destination: boolean
content: boolean
pathname?: string
center?: string
}
function category(sample: FirstNavigationSample) {
if (sample.destination && !sample.source) return "destination"
if (sample.source && !sample.destination) return "source"
if (!sample.content) return "blank"
return "unknown"
}
export function summarizeFirstNavigation(samples: FirstNavigationSample[]) {
const categories = samples.map(category)
const stable = categories.findIndex(
(value, index) =>
value === "destination" && categories[index + 1] === "destination" && categories[index + 2] === "destination",
)
return {
samples: samples.length,
firstDestinationObservedMs: samples[categories.indexOf("destination")]?.observedAtMs ?? null,
stableDestinationObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
sourceSamples: categories.filter((value) => value === "source").length,
blankSamples: categories.filter((value) => value === "blank").length,
unknownSamples: categories.filter((value) => value === "unknown").length,
destinationSamples: categories.filter((value) => value === "destination").length,
}
}
@@ -1,86 +0,0 @@
import type { Page } from "@playwright/test"
import { summarizeFirstNavigation, type FirstNavigationSample } from "./first-navigation-metrics"
type FirstNavigationProbe = {
samples: FirstNavigationSample[]
stop: () => void
}
export async function measureFirstNavigation(
page: Page,
input: {
href: string
destinationPath: string
sourceSelector: string
destinationSelector: string
contentSelector: string
navigate: () => Promise<void>
},
) {
await page.evaluate(
({ href, destinationPath, sourceSelector, destinationSelector, contentSelector }) => {
const samples: FirstNavigationSample[] = []
let started: number | undefined
let running = true
const visible = (selector: string) =>
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
})
const sample = () => {
if (!running || started === undefined) return
requestAnimationFrame(() => {
setTimeout(() => {
if (!running || started === undefined) return
samples.push({
observedAtMs: performance.now() - started,
source: visible(sourceSelector),
destination: `${location.pathname}${location.search}` === destinationPath && visible(destinationSelector),
content: visible(contentSelector),
pathname: `${location.pathname}${location.search}`,
center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
})
sample()
}, 0)
})
}
document.addEventListener(
"click",
(event) => {
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
sample()
},
{ capture: true, once: true },
)
;(window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe = {
samples,
stop: () => {
running = false
},
}
},
{
href: input.href,
destinationPath: input.destinationPath,
sourceSelector: input.sourceSelector,
destinationSelector: input.destinationSelector,
contentSelector: input.contentSelector,
},
)
await input.navigate()
await page.waitForFunction(() => {
const samples = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe
?.samples
if (!samples) return false
return samples.length >= 3 && samples.slice(-3).every((sample) => sample.destination && !sample.source)
})
const samples = await page.evaluate(() => {
const probe = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe!
probe.stop()
return probe.samples
})
return { summary: summarizeFirstNavigation(samples), samples }
}
@@ -1,114 +0,0 @@
import { benchmark, expect } from "../benchmark"
import { expectSessionTitle } from "../../utils/waits"
import { measureNavigationMilestones } from "./navigation-milestones"
import { fixture } from "./session-timeline-stress.fixture"
import {
installStressSessionTabs,
installTimelineSettings,
mockStressTimeline,
stressSessionHref,
} from "./timeline-test-helpers"
import { waitForStableTimeline } from "./session-tab-switch-probe"
const homeRow = '[data-component="home-session-row"]'
const homeShell = '[data-component="home-session-search"]'
benchmark.describe("performance: home and tab navigation", () => {
benchmark("opens a home session and paints its titlebar tab", async ({ page, report }) => {
await setup(page, [])
await page.goto("/")
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
await expect(row).toBeVisible()
const href = stressSessionHref(fixture.targetID)
const result = await measureNavigationMilestones(page, {
triggerSelector: homeRow,
milestones: {
content: { selector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!) },
tab: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]` },
},
navigate: async () => {
await row.click()
await expectSessionTitle(page, fixture.expected.targetTitle)
},
})
report(result)
await expect(page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)).toContainText(
fixture.expected.targetTitle,
)
})
benchmark("stages the review body after cold session content", async ({ page, report }) => {
await setup(page, [])
await page.goto("/")
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
await expect(row).toBeVisible()
const result = await page.evaluate(
({ rowSelector, title, contentSelector }) =>
new Promise<{ contentBeforeReview: boolean; samples: number }>((resolve) => {
let samples = 0
const sample = () => {
samples++
const content = !!document.querySelector(contentSelector)
const review = !!document.querySelector('[data-component="session-review"]')
if (content && !review) {
resolve({ contentBeforeReview: true, samples })
return
}
if (content && review) {
resolve({ contentBeforeReview: false, samples })
return
}
requestAnimationFrame(sample)
}
const target = [...document.querySelectorAll<HTMLElement>(rowSelector)].find((item) =>
item.textContent?.includes(title),
)
if (!target) throw new Error(`Home session row not found: ${title}`)
target.click()
requestAnimationFrame(sample)
}),
{
rowSelector: homeRow,
title: fixture.expected.targetTitle,
contentSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
},
)
report(result)
expect(result.contentBeforeReview).toBe(true)
await expect(page.locator('[data-component="session-review"]')).toBeVisible()
})
benchmark("closes the only session tab and paints home", async ({ page, report }) => {
await setup(page, [fixture.sourceID])
const href = stressSessionHref(fixture.sourceID)
await page.goto(href)
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
const close = tab.locator("..").locator('[data-component="icon-button-v2"]')
await expect(close).toBeVisible()
const result = await measureNavigationMilestones(page, {
triggerSelector: '[data-slot="titlebar-tabs"] [data-component="icon-button-v2"]',
milestones: {
home: { selector: homeShell },
row: { selector: homeRow },
tabRemoved: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]`, visible: false },
},
navigate: async () => {
await close.click()
await expect(page).toHaveURL("/")
},
})
report(result)
})
})
async function setup(page: Parameters<typeof mockStressTimeline>[0], sessionIDs: string[]) {
await mockStressTimeline(page)
await installTimelineSettings(page)
await installStressSessionTabs(page, { sessionIDs })
}
function messageSelector(id: string) {
return `[data-message-id="${id}"]`
}
@@ -1,128 +0,0 @@
import type { Page } from "@playwright/test"
export type NavigationMilestoneSample = {
observedAtMs: number
milestones: Record<string, boolean>
}
export function summarizeNavigationMilestones(samples: NavigationMilestoneSample[]) {
const names = Object.keys(samples[0]?.milestones ?? {})
const summarize = (matches: (sample: NavigationMilestoneSample) => boolean) => {
const first = samples.find(matches)
const stable = samples.findIndex(
(sample, index) =>
index + 2 < samples.length && matches(sample) && matches(samples[index + 1]!) && matches(samples[index + 2]!),
)
return {
firstObservedMs: first?.observedAtMs ?? null,
stableObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
}
}
return {
samples: samples.length,
milestones: Object.fromEntries(
names.map((name) => [name, summarize((sample) => sample.milestones[name] === true)]),
),
all: summarize((sample) => names.every((name) => sample.milestones[name] === true)),
}
}
type NavigationMilestoneProbe = {
samples: NavigationMilestoneSample[]
stop: () => void
}
export async function measureNavigationMilestones(
page: Page,
input: {
triggerSelector: string
milestones: Record<string, { selector: string; visible?: boolean }>
navigate: () => Promise<void>
},
) {
await page.evaluate(
({ triggerSelector, milestones }) => {
const samples: NavigationMilestoneSample[] = []
const streaks = new Map<string, number>()
const marked = new Set<string>()
let started: number | undefined
let running = true
const visible = (selector: string) =>
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
})
const sample = () => {
if (!running || started === undefined) return
requestAnimationFrame(() => {
setTimeout(() => {
if (!running || started === undefined) return
const current = Object.fromEntries(
Object.entries(milestones).map(([name, milestone]) => [
name,
milestone.visible === false ? !document.querySelector(milestone.selector) : visible(milestone.selector),
]),
)
samples.push({
observedAtMs: performance.now() - started,
milestones: current,
})
Object.entries(current).forEach(([name, value]) => {
if (!value) {
streaks.set(name, 0)
return
}
if (!marked.has(`${name}.first`)) {
performance.mark(`opencode.navigation.${name}.first`)
marked.add(`${name}.first`)
}
const streak = (streaks.get(name) ?? 0) + 1
streaks.set(name, streak)
if (streak === 3) performance.mark(`opencode.navigation.${name}.stable`)
})
const all = Object.values(current).every(Boolean)
const allStreak = all ? (streaks.get("all") ?? 0) + 1 : 0
streaks.set("all", allStreak)
if (all && !marked.has("all.first")) {
performance.mark("opencode.navigation.all.first")
marked.add("all.first")
}
if (allStreak === 3) performance.mark("opencode.navigation.all.stable")
sample()
}, 0)
})
}
document.addEventListener(
"click",
(event) => {
if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
started = performance.now()
performance.mark("opencode.navigation.click")
sample()
},
{ capture: true, once: true },
)
;(window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones = {
samples,
stop: () => {
running = false
},
}
},
{ triggerSelector: input.triggerSelector, milestones: input.milestones },
)
await input.navigate()
await page.waitForFunction(() => {
const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
?.samples
if (!samples || samples.length < 3) return false
return samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
})
const samples = await page.evaluate(() => {
const probe = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!
probe.stop()
return probe.samples
})
return { summary: summarizeNavigationMilestones(samples), samples }
}
@@ -47,21 +47,3 @@ benchmark("samples cached session repaint after the click", async ({ page, repor
report(compressCachedRepaintTrace(result)) report(compressCachedRepaintTrace(result))
expect(result.samples.length).toBeGreaterThan(0) expect(result.samples.length).toBeGreaterThan(0)
}) })
benchmark("prefetches every open session tab", async ({ page, report }) => {
const prefetched = new Set<string>()
await mockStressTimeline(page, {
onMessages: (input) => {
if (!input.before && input.phase === "start") prefetched.add(input.sessionID)
},
})
await installStressSessionTabs(page, {
sessionIDs: [fixture.sourceID, fixture.targetID, fixture.childID],
})
await installTimelineSettings(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await expect.poll(() => prefetched.has(fixture.childID)).toBe(true)
report({ prefetched: [...prefetched] })
})
@@ -119,6 +119,7 @@ export async function setupTimelineBenchmark(page: Page, options: { historyTurns
editToolPartsExpanded: true, editToolPartsExpanded: true,
shellToolPartsExpanded: true, shellToolPartsExpanded: true,
showReasoningSummaries: true, showReasoningSummaries: true,
showSessionProgressBar: true,
}, },
}), }),
) )
@@ -23,7 +23,6 @@ const words = [
const sourceID = "ses_smoke_source" const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target" const targetID = "ses_smoke_target"
const childID = "ses_smoke_child"
const directory = "C:/OpenCode/SmokeProject" const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline" const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
@@ -127,11 +126,9 @@ function toolPart(
tool: string, tool: string,
input: Record<string, unknown>, input: Record<string, unknown>,
outputLength = 160, outputLength = 160,
metadataOverride?: Record<string, unknown>,
): MessagePart { ): MessagePart {
const metadata = const metadata =
metadataOverride ?? tool === "apply_patch"
(tool === "apply_patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write" : tool === "edit" || tool === "write"
? { ? {
@@ -141,7 +138,7 @@ function toolPart(
} }
: tool === "question" : tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] } ? { answers: [["Proceed"], ["Keep sample output"]] }
: {}) : {}
return { return {
id: id(`prt_tool_${tool}_${partIndex}`, index), id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool", type: "tool",
@@ -247,25 +244,7 @@ function turn(index: number): Message[] {
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat() const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
const sourceMessages = Array.from({ length: 12 }, (_, index) => [ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
userMessage(sourceID, index + 1000, 120), userMessage(sourceID, index + 1000, 120),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [ assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
textPart(index + 1000, 0, 240),
...(index === 11
? [
toolPart(
index + 1000,
1,
"task",
{ description: "Inspect child navigation", subagent_type: "explore" },
160,
{ sessionId: childID },
),
]
: []),
]),
]).flat()
const childMessages = Array.from({ length: 4 }, (_, index) => [
userMessage(childID, index + 2000, 120),
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
]).flat() ]).flat()
function renderable(part: MessagePart) { function renderable(part: MessagePart) {
@@ -319,32 +298,19 @@ export const fixture = {
version: "dev", version: "dev",
time: { created: 1700000001000, updated: 1700000001000 }, time: { created: 1700000001000, updated: 1700000001000 },
}, },
{
id: childID,
parentID: sourceID,
slug: "child",
projectID,
directory,
title: "Inspect child navigation",
version: "dev",
time: { created: 1700000002000, updated: 1700000002000 },
},
], ],
sourceID, sourceID,
targetID, targetID,
childID, messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages },
expected: { expected: {
sourceTitle: "Uncommitted changes inquiry", sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis", targetTitle: "Example Game: sample jump movement & sample physics analysis",
childTitle: "Inspect child navigation",
sourceMessageIDs: sourceMessages sourceMessageIDs: sourceMessages
.filter((message) => message.info.role === "user") .filter((message) => message.info.role === "user")
.map((message) => message.info.id), .map((message) => message.info.id),
targetMessageIDs: targetMessages targetMessageIDs: targetMessages
.filter((message) => message.info.role === "user") .filter((message) => message.info.role === "user")
.map((message) => message.info.id), .map((message) => message.info.id),
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) => targetPartIDs: targetMessages.flatMap((message) =>
orderedParts(message) orderedParts(message)
.filter(renderable) .filter(renderable)
@@ -1,7 +1,7 @@
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../../utils/mock-server" import { mockOpenCodeServer } from "../../utils/mock-server"
import { fixture, pageMessages } from "./session-timeline-stress.fixture" import { fixture } from "./session-timeline-stress.fixture"
export async function installTimelineSettings(page: Page) { export async function installTimelineSettings(page: Page) {
await page.addInitScript(() => { await page.addInitScript(() => {
@@ -9,34 +9,30 @@ export async function installTimelineSettings(page: Page) {
"settings.v3", "settings.v3",
JSON.stringify({ JSON.stringify({
general: { general: {
newLayoutDesigns: true,
editToolPartsExpanded: true, editToolPartsExpanded: true,
shellToolPartsExpanded: true, shellToolPartsExpanded: true,
showReasoningSummaries: true, showReasoningSummaries: true,
showSessionProgressBar: true,
}, },
}), }),
) )
}) })
} }
export function mockStressTimeline( export function mockStressTimeline(page: Page) {
page: Page,
input?: { onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void },
) {
return mockOpenCodeServer(page, { return mockOpenCodeServer(page, {
sessions: fixture.sessions, sessions: fixture.sessions,
provider: fixture.provider, provider: fixture.provider,
directory: fixture.directory, directory: fixture.directory,
project: fixture.project, project: fixture.project,
pageMessages, pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
onMessages: input?.onMessages,
}) })
} }
export async function installStressSessionTabs(page: Page, input?: { draftID?: string; sessionIDs?: string[] }) { export async function installStressSessionTabs(page: Page) {
const server = stressServer() const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript( await page.addInitScript(
({ directory, sessionIDs, dirBase64, server, draftID }) => { ({ directory, sourceID, targetID, dirBase64, server }) => {
localStorage.setItem( localStorage.setItem(
"opencode.global.dat:server", "opencode.global.dat:server",
JSON.stringify({ JSON.stringify({
@@ -46,35 +42,26 @@ export async function installStressSessionTabs(page: Page, input?: { draftID?: s
) )
localStorage.setItem( localStorage.setItem(
"opencode.global.dat:tabs", "opencode.global.dat:tabs",
JSON.stringify([ JSON.stringify(
...sessionIDs.map((sessionId) => ({ [sourceID, targetID].map((sessionId) => ({
type: "session", type: "session",
server, server,
dirBase64, dirBase64,
sessionId, sessionId,
})), })),
...(draftID ? [{ type: "draft", draftID, server, directory }] : []), ),
]),
) )
}, },
{ {
directory: fixture.directory, directory: fixture.directory,
sessionIDs: input?.sessionIDs ?? [fixture.sourceID, fixture.targetID], sourceID: fixture.sourceID,
targetID: fixture.targetID,
dirBase64: base64Encode(fixture.directory), dirBase64: base64Encode(fixture.directory),
server, server,
draftID: input?.draftID,
}, },
) )
} }
export function stressSessionHref(sessionID: string) { export function stressSessionHref(sessionID: string) {
return `/server/${base64Encode(stressServer())}/session/${sessionID}` return `/${base64Encode(fixture.directory)}/session/${sessionID}`
}
export function stressDraftHref(draftID: string) {
return `/new-session?draftId=${encodeURIComponent(draftID)}`
}
function stressServer() {
return `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
} }
@@ -1,32 +0,0 @@
import { expect, test } from "bun:test"
import { summarizeFirstNavigation } from "../timeline/first-navigation-metrics"
test("reports blank frames before first destination and stable paint", () => {
expect(
summarizeFirstNavigation([
{ observedAtMs: 16, source: true, destination: false, content: true },
{ observedAtMs: 32, source: false, destination: false, content: false },
{ observedAtMs: 48, source: false, destination: true, content: true },
{ observedAtMs: 64, source: false, destination: true, content: true },
{ observedAtMs: 80, source: false, destination: true, content: true },
]),
).toEqual({
samples: 5,
firstDestinationObservedMs: 48,
stableDestinationObservedMs: 80,
sourceSamples: 1,
blankSamples: 1,
unknownSamples: 0,
destinationSamples: 3,
})
})
test("does not report stability for interrupted destination frames", () => {
expect(
summarizeFirstNavigation([
{ observedAtMs: 16, source: false, destination: true, content: true },
{ observedAtMs: 32, source: false, destination: false, content: true },
{ observedAtMs: 48, source: false, destination: true, content: true },
]).stableDestinationObservedMs,
).toBeNull()
})
@@ -1,34 +0,0 @@
import { expect, test } from "bun:test"
import { summarizeNavigationMilestones } from "../timeline/navigation-milestones"
test("reports first and stable paint for each navigation milestone", () => {
expect(
summarizeNavigationMilestones([
{ observedAtMs: 16, milestones: { content: false, tab: false } },
{ observedAtMs: 32, milestones: { content: true, tab: false } },
{ observedAtMs: 48, milestones: { content: true, tab: true } },
{ observedAtMs: 64, milestones: { content: true, tab: true } },
{ observedAtMs: 80, milestones: { content: true, tab: true } },
]),
).toEqual({
samples: 5,
milestones: {
content: { firstObservedMs: 32, stableObservedMs: 64 },
tab: { firstObservedMs: 48, stableObservedMs: 80 },
},
all: { firstObservedMs: 48, stableObservedMs: 80 },
})
})
test("reports missing stability when a milestone appears in the final samples", () => {
expect(
summarizeNavigationMilestones([
{ observedAtMs: 16, milestones: { content: false } },
{ observedAtMs: 32, milestones: { content: true } },
]),
).toEqual({
samples: 2,
milestones: { content: { firstObservedMs: 32, stableObservedMs: null } },
all: { firstObservedMs: 32, stableObservedMs: null },
})
})
@@ -1,10 +0,0 @@
import { expect, test } from "bun:test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { fixture } from "../timeline/session-timeline-stress.fixture"
import { stressSessionHref } from "../timeline/timeline-test-helpers"
test("builds stress session links for the benchmark server", () => {
expect(stressSessionHref(fixture.sourceID)).toBe(
`/server/${base64Encode("http://127.0.0.1:4096")}/session/${fixture.sourceID}`,
)
})
@@ -1,157 +0,0 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewLineCommentRegression"
const sessionID = "ses_review_line_comment_regression"
const title = "Review line comment regression"
test.beforeEach(async ({ page }) => {
await openReview(page)
})
test("opens the comment editor when code is clicked", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const line = review.getByText("export const value = 'after'", { exact: true })
await expectAppVisible(line)
await line.click()
await expect(review.getByRole("textbox")).toBeVisible()
})
test("opens the comment editor when a line number is clicked", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)
await lineNumber.click()
await expect(review.getByRole("textbox")).toBeVisible()
})
test("opens the comment editor for a line number range", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const start = review.locator('[data-column-number="1"]').last()
const end = review.locator('[data-column-number="3"]').last()
await expectAppVisible(start)
await expectAppVisible(end)
const from = await start.boundingBox()
const to = await end.boundingBox()
if (!from || !to) throw new Error("Missing line number bounds")
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
await page.mouse.down()
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2)
await page.mouse.up()
await expect(review.getByRole("textbox")).toBeVisible()
})
test("shows a comment button when a line number is hovered", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)
const comment = review.getByRole("button", { name: "Comment", exact: true })
await expect(async () => {
await page.mouse.move(0, 0)
await lineNumber.hover()
await expect(comment).toBeVisible({ timeout: 500 })
await comment.click({ timeout: 500 })
}).toPass()
await expect(review.getByRole("textbox")).toBeVisible()
})
test("stages a submitted line comment in the prompt context", async ({ page }) => {
const requests: string[] = []
page.on("request", (request) => {
if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`)
})
const review = page.locator('[data-component="session-review"]')
await review.getByText("export const value = 'after'", { exact: true }).click()
await review.getByRole("textbox").fill("Use the existing value instead")
await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click()
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "Session" }).click()
const context = page.getByText("Use the existing value instead", { exact: true }).last()
await expect(context).toBeVisible()
await expect(context.locator("..")).toContainText("review.ts:2")
expect(requests).toEqual([])
})
async function openReview(page: Page) {
await page.setViewportSize({ width: 700, height: 900 })
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_review_line_comment_regression",
worktree: directory,
vcs: "git",
name: "review-line-comment-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: sessionID,
slug: "review-line-comment-regression",
projectID: "proj_review_line_comment_regression",
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [
{
file: "src/review.ts",
additions: 1,
deletions: 1,
status: "modified",
patch:
"diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
},
],
pageMessages: () => ({
items: [
{
info: {
id: "msg_review_line_comment_regression",
sessionID,
role: "user",
time: { created: 1700000000000 },
summary: { diffs: [] },
agent: "build",
model: { providerID: "opencode", modelID: "test" },
},
parts: [
{
id: "prt_review_line_comment_regression",
sessionID,
messageID: "msg_review_line_comment_regression",
type: "text",
text: "Review this change.",
},
],
},
],
}),
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
await page.getByRole("tab", { name: "Changes" }).click()
expect(await (await diffResponse).json()).toHaveLength(1)
const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review)
await review
.getByRole("heading", { name: /review\.ts/ })
.getByRole("button")
.first()
.click()
}
@@ -261,6 +261,7 @@ async function configurePage(page: Page) {
editToolPartsExpanded: true, editToolPartsExpanded: true,
shellToolPartsExpanded: true, shellToolPartsExpanded: true,
showReasoningSummaries: true, showReasoningSummaries: true,
showSessionProgressBar: true,
}, },
}), }),
) )
@@ -47,6 +47,7 @@ async function configurePage(page: Page) {
editToolPartsExpanded: true, editToolPartsExpanded: true,
shellToolPartsExpanded: true, shellToolPartsExpanded: true,
showReasoningSummaries: true, showReasoningSummaries: true,
showSessionProgressBar: true,
}, },
}), }),
) )
@@ -21,7 +21,6 @@ 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"
@@ -140,7 +139,7 @@ function toolPart(
status: "completed", status: "completed",
input, input,
output: lorem(index * 23 + partIndex, outputLength), output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed", title: tool === "bash" ? "Verify generated output" : 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 },
}, },
@@ -201,7 +200,9 @@ 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 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 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 % 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
@@ -241,7 +242,6 @@ function orderedParts(message: Message) {
export const fixture = { export const fixture = {
directory, directory,
serverKey,
project: { project: {
id: projectID, id: projectID,
worktree: directory, worktree: directory,
@@ -295,7 +295,6 @@ 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,
}, },
} }
@@ -58,18 +58,7 @@ test.describe("smoke: session timeline", () => {
await page.mouse.wheel(0, -120) await page.mouse.wheel(0, -120)
await page.waitForTimeout(20) await page.waitForTimeout(20)
} }
const keys = await scroller.evaluate((element) => { const keys = ["prt_user_text_smoke_0032", "prt_text_2_smoke_0032", "prt_tool_apply_patch_8_smoke_0032"]
const view = element.getBoundingClientRect()
return [...element.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
.filter((row) => {
const rect = row.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((row) => row.dataset.timelinePartId)
.filter((id): id is string => !!id)
.slice(0, 3)
})
expect(keys.length).toBeGreaterThan(0)
const positions = () => const positions = () =>
scroller.evaluate((element, keys) => { scroller.evaluate((element, keys) => {
const top = element.getBoundingClientRect().top const top = element.getBoundingClientRect().top
@@ -338,18 +327,6 @@ 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)
}) })
}) })
@@ -362,6 +339,7 @@ async function configureSmokePage(page: Page, directory: string) {
editToolPartsExpanded: true, editToolPartsExpanded: true,
shellToolPartsExpanded: true, shellToolPartsExpanded: true,
showReasoningSummaries: true, showReasoningSummaries: true,
showSessionProgressBar: true,
}, },
}), }),
) )
@@ -728,7 +706,7 @@ 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 = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}` const href = `/${base64Encode(fixture.directory)}/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()
+4 -13
View File
@@ -18,7 +18,6 @@ export interface MockServerConfig {
project: unknown project: unknown
sessions: ({ id: string } & Record<string, unknown>)[] sessions: ({ id: string } & Record<string, unknown>)[]
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string } pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
vcsDiff?: unknown[]
messageDelay?: number messageDelay?: number
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
events?: () => unknown[] events?: () => unknown[]
@@ -26,8 +25,6 @@ export interface MockServerConfig {
} }
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const cursors = new Map<string, string>()
let nextCursor = 0
const staticRoutes: Record<string, unknown> = { const staticRoutes: Record<string, unknown> = {
"/provider": config.provider, "/provider": config.provider,
"/path": { "/path": {
@@ -55,7 +52,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
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)
if (path === "/global/health") return json(route, { healthy: true }) if (path === "/global/health") return json(route, { healthy: true })
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
if (emptyObject.has(path)) return json(route, {}) if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, []) if (emptyList.has(path)) return json(route, [])
if (path in staticRoutes) return json(route, staticRoutes[path]) if (path in staticRoutes) return json(route, staticRoutes[path])
@@ -70,18 +66,13 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) { if (messagesMatch) {
const token = url.searchParams.get("before") ?? undefined const before = url.searchParams.get("before") ?? undefined
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" }) config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const limit = Number(url.searchParams.get("limit") ?? 80) const limit = Number(url.searchParams.get("limit") ?? 80)
const pageData = config.pageMessages(messagesMatch[1], limit, before) const pageData = config.pageMessages(messagesMatch[1], limit, before)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" }) config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
if (!pageData.cursor) return json(route, pageData.items) return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
const cursor = `cursor_${++nextCursor}`
cursors.set(cursor, pageData.cursor)
return json(route, pageData.items, { "x-next-cursor": cursor })
} }
if (url.port === targetPort && targetPort !== appPort) return json(route, {}) if (url.port === targetPort && targetPort !== appPort) return json(route, {})
@@ -89,9 +80,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}) })
} }
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) { function json(route: Route, body: unknown, headers?: Record<string, string>) {
return route.fulfill({ return route.fulfill({
status, status: 200,
contentType: "application/json", contentType: "application/json",
headers: { headers: {
"access-control-allow-origin": "*", "access-control-allow-origin": "*",
+5 -11
View File
@@ -1,28 +1,22 @@
<!doctype html> <!doctype html>
<html lang="en" style="background-color: var(--v2-background-bg-deep, #fafafa)"> <html lang="en" style="background-color: var(--background-base)">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
/>
<title>OpenCode</title> <title>OpenCode</title>
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" /> <link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
<link rel="shortcut icon" href="/favicon-v3.ico" /> <link rel="shortcut icon" href="/favicon-v3.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#fafafa" /> <meta name="theme-color" content="#F8F7F7" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta property="og:image" content="/social-share.png" /> <meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script> <script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
</head> </head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep"> <body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div> <div id="root" class="flex flex-col h-dvh p-px"></div>
<script src="/src/entry.tsx" type="module"></script> <script src="/src/entry.tsx" type="module"></script>
</body> </body>
</html> </html>
+1 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.17.10", "version": "1.17.9",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -47,7 +47,6 @@
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4", "@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:", "@sentry/solid": "catalog:",
+1 -2
View File
@@ -15,11 +15,10 @@
document.documentElement.dataset.theme = themeId document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode document.documentElement.dataset.colorScheme = mode
document.documentElement.style.backgroundColor = isDark ? "#080808" : "#fafafa"
// Update theme-color meta tag to match app color scheme // Update theme-color meta tag to match app color scheme
var metas = document.querySelectorAll("meta[name='theme-color']") var metas = document.querySelectorAll("meta[name='theme-color']")
if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#080808" : "#fafafa") if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#131010" : "#F8F7F7")
if (themeId === "oc-2") return if (themeId === "oc-2") return
+77 -197
View File
@@ -4,7 +4,7 @@ import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/session-ui/file" import { File } from "@opencode-ai/ui/file"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo" import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
@@ -30,8 +30,8 @@ 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, useServerSDK } from "@/context/server-sdk" import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider, useServerSync } 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"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
@@ -47,18 +47,16 @@ 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 LegacyLayout from "@/pages/layout" import Layout 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, sessionHref } from "./utils/session-route"
import Session from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home"
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const NewSession = lazy(() => import("@/pages/new-session")) const NewSession = lazy(() => import("@/pages/new-session"))
const SessionRoute = () => { const SessionRoute = Object.assign(
() => {
const settings = useSettings() const settings = useSettings()
const params = useParams() const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>() const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
@@ -66,10 +64,6 @@ const SessionRoute = () => {
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(() => {
@@ -84,104 +78,46 @@ const SessionRoute = () => {
<Session /> <Session />
</SessionProviders> </SessionProviders>
) )
}
const TargetSessionRoute = () => {
const params = useParams<{ serverKey: string; id: string }>()
const server = useServer()
const conn = createMemo(() => {
const key = requireServerKey(params.serverKey)
return server.list.find((item) => ServerConnection.key(item) === key)
})
return (
<Show when={`${params.serverKey}\0${params.id}`} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ResolvedTargetSessionRoute />
</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
}
function ResolvedTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
const settings = useSettings()
const tabs = useTabs()
const sync = useServerSync()
const serverKey = createMemo(() => requireServerKey(params.serverKey))
const cached = createMemo(() => sync().session.lineage.peek(params.id))
const [resolved] = createResource(
() => {
if (cached()) return
return { id: params.id, sync: sync() }
}, },
({ id, sync }) => sync.session.lineage.resolve(id), { preload: Session.preload },
) )
const current = createMemo(() => cached() ?? resolved())
const directory = createMemo(() => current()?.session.directory)
const targetDirectory = () => directory()!
createEffect(() => {
const session = current()
if (!session) return
tabs.addSessionTab({
server: serverKey(),
sessionId: session.root.id,
})
})
return (
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
<Show when={directory()}>
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<TargetSessionPage />
</DirectoryDataProvider>
</SDKProvider>
</Show>
</Show>
</Show>
</TargetServerScopedProviders>
)
}
function TargetSessionPage() {
const sdk = useSDK()
const serverSDK = useServerSDK()
return (
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
<SessionProviders>
<Session />
</SessionProviders>
</Show>
)
}
// 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 SelectedServerProviders(props: ParentProps) { function SelectedServerLayout(props: ParentProps) {
return ( return (
<ServerKey> <ServerKey>
<ServerSDKProvider> <ServerSDKProvider>
<ServerSyncProvider>{props.children}</ServerSyncProvider> <ServerSyncProvider>
<ServerScopedShell>{props.children}</ServerScopedShell>
</ServerSyncProvider>
</ServerSDKProvider> </ServerSDKProvider>
</ServerKey> </ServerKey>
) )
} }
function LegacyServerLayout(props: ParentProps) { // Wraps /new-session. It resolves the draft's target server and provides the
// server-scoped shell for that server — without ServerKey, so the page never depends
// on the globally "selected" server.
function DraftServerLayout(props: ParentProps) {
const server = useServer()
const tabs = useTabs()
const [search] = useSearchParams<{ draftId?: string }>()
const conn = createMemo(() => {
const id = search.draftId
if (!id) return undefined
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
if (!draft) return undefined
return server.list.find((c) => ServerConnection.key(c) === draft.server)
})
return ( return (
<SelectedServerProviders> <ServerSDKProvider server={conn}>
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell> <ServerSyncProvider server={conn}>
</SelectedServerProviders> <ServerScopedShell>{props.children}</ServerScopedShell>
</ServerSyncProvider>
</ServerSDKProvider>
) )
} }
@@ -190,37 +126,37 @@ function DraftRoute() {
const tabs = useTabs() const tabs = useTabs()
return ( return (
<Show when={tabs.ready()}> <Show when={tabs.ready()}>
<Show <Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)} {(draftID) => <ResolvedDraftRoute draftID={draftID} />}
keyed
fallback={<Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
</Show> </Show>
</Show> </Show>
) )
} }
function ResolvedDraftRoute(props: { draft: DraftTab }) { function ResolvedDraftRoute(props: { draftID: string }) {
const server = useServer() const tabs = useTabs()
const conn = createMemo(() => server.list.find((item) => ServerConnection.key(item) === props.draft.server)) const draft = createMemo(() =>
const directory = () => props.draft.directory tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
const serverKey = () => props.draft.server )
// 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 (
<ServerSDKProvider server={conn}> <Show when={directory()} keyed>
<ServerSyncProvider server={conn}> {(dir) => (
<TargetServerScopedProviders directory={directory}> <SDKProvider directory={dir}>
<SDKProvider directory={directory}> <DirectoryDataProvider directory={dir} draftID={props.draftID}>
<DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders> <DraftProviders>
<NewSession /> <NewSession />
</DraftProviders> </DraftProviders>
</DirectoryDataProvider> </DirectoryDataProvider>
</SDKProvider> </SDKProvider>
</TargetServerScopedProviders> )}
</ServerSyncProvider> </Show>
</ServerSDKProvider>
) )
} }
@@ -274,61 +210,32 @@ 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 shared by the legacy shell and the top-level new shell. // Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
type ServerScopedShellProps = ParentProps<{ // each per-route server layout so they resolve to that route's server (selected vs
directory?: () => string | undefined // draft). The Layout remounts when crossing between those groups.
sessionID?: () => string | undefined function ServerScopedShell(props: ParentProps) {
}>
function ServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<PermissionProvider directory={props.directory}> <PermissionProvider>
<LayoutProvider> <LayoutProvider>
<NotificationProvider directory={props.directory} sessionID={props.sessionID}> <NotificationProvider>
<ModelsProvider>{props.children}</ModelsProvider> <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 NewAppLayout(props: ParentProps) {
return (
<SelectedServerProviders>
<ServerScopedProviders>
<NewLayout>{props.children}</NewLayout>
</ServerScopedProviders>
</SelectedServerProviders>
)
}
function TargetServerScopedProviders(props: ServerScopedShellProps) {
return (
<PermissionProvider directory={props.directory}>
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider>{props.children}</ModelsProvider>
</NotificationProvider>
</PermissionProvider>
)
}
function SessionProviders(props: ParentProps) { function SessionProviders(props: ParentProps) {
return ( return (
<TerminalProvider> <TerminalProvider>
@@ -511,9 +418,11 @@ export function AppInterface(props: {
router?: Component<BaseRouterProps> router?: Component<BaseRouterProps>
disableHealthCheck?: boolean disableHealthCheck?: boolean
}) { }) {
// The visual new layout lives in the router root so it remains mounted across // The shared shell holds only server-agnostic providers (QueryClient + Settings/
// route changes. Draft and session routes override only their server-bound data // Command/Highlights) and stays mounted across every route. The server-scoped
// providers beneath it. // providers and the visual Layout live in the per-route layouts below, so they
// resolve to that route's server (selected for most routes, the draft's server for
// /new-session). appChildren is server-agnostic, so it renders here once.
const ServerShell = (shellProps: ParentProps) => ( const ServerShell = (shellProps: ParentProps) => (
<QueryProvider> <QueryProvider>
<SharedProviders> <SharedProviders>
@@ -530,57 +439,28 @@ export function AppInterface(props: {
servers={props.servers} servers={props.servers}
> >
<GlobalProvider> <GlobalProvider>
<SettingsProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}> <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
<Dynamic <Dynamic
component={props.router ?? Router} component={props.router ?? Router}
root={(routerProps) => ( root={(routerProps) => (
<TabsProvider> <TabsProvider>
<ServerShell> <ServerShell>{routerProps.children}</ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</TabsProvider> </TabsProvider>
)} )}
> >
<Routes /> <Route component={SelectedServerLayout}>
</Dynamic> <Route path="/" component={HomeRoute} />
</Show>
</ConnectionGate>
</SettingsProvider>
</GlobalProvider>
</ServerProvider>
)
}
function Routes() {
const settings = useSettings()
return (
<>
<Route component={LegacyServerLayout}>
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
<Route path="/:dir" component={DirectoryLayout}> <Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} /> <Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} /> <Route path="/session/:id?" component={SessionRoute} />
</Route> </Route>
</Route> </Route>
<Show when={settings.general.newLayoutDesigns()}> <Route component={DraftServerLayout}>
<Route path="/" component={NewHome} />
<Route
path="/:dir/session/:id"
component={() => {
const server = useServer()
const { id } = useParams()
return <Navigate href={`/server/${server.key}/session/${id}`} />
}}
/>
</Show>
<Route path="/new-session" component={DraftRoute} /> <Route path="/new-session" component={DraftRoute} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} /> </Route>
</> </Dynamic>
</ConnectionGate>
</GlobalProvider>
</ServerProvider>
) )
} }
+1 -1
View File
@@ -363,7 +363,7 @@ export function DebugBar() {
return ( return (
<aside <aside
aria-label={language.t("debugBar.ariaLabel")} aria-label={language.t("debugBar.ariaLabel")}
class="pointer-events-auto fixed bottom-3 right-3 z-50 hidden w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] md:block sm:bottom-4 sm:right-4 sm:w-[324px]" class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
> >
<div class="grid grid-cols-5 gap-px font-mono"> <div class="grid grid-cols-5 gap-px font-mono">
<Cell <Cell
@@ -20,7 +20,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
const dialog = useDialog() const dialog = useDialog()
const global = useGlobal() const global = useGlobal()
const language = useLanguage() const language = useLanguage()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server)) const serverCtx = createMemo(() => global.createServerCtx(props.server))
const serverSDK = () => serverCtx().sdk const serverSDK = () => serverCtx().sdk
const serverSync = () => serverCtx().sync const serverSync = () => serverCtx().sync
@@ -19,7 +19,6 @@ import {
pickerMode, pickerMode,
preloadTreeDirectories, preloadTreeDirectories,
cleanPickerInput, cleanPickerInput,
createPriorityTaskQueue,
createDirectorySearch, createDirectorySearch,
currentPickerSuggestions, currentPickerSuggestions,
displayPickerPath, displayPickerPath,
@@ -39,7 +38,7 @@ interface DialogSelectDirectoryV2Props {
export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const global = useGlobal() const global = useGlobal()
const { sync, sdk } = global.ensureServerCtx(props.server) const { sync, sdk } = global.createServerCtx(props.server)
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const policy = pickerMode(props.mode ?? "directory", props.start) const policy = pickerMode(props.mode ?? "directory", props.start)
@@ -56,7 +55,6 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const [error, setError] = createSignal(false) const [error, setError] = createSignal(false)
const [rootValid, setRootValid] = createSignal(false) const [rootValid, setRootValid] = createSignal(false)
const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>() const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>()
const loads = createPriorityTaskQueue<Array<{ name: string; type: "file" | "directory" }> | undefined>(3)
const advanced = new Set<string>() const advanced = new Set<string>()
let tree: FileTree | undefined let tree: FileTree | undefined
let container: HTMLDivElement | undefined let container: HTMLDivElement | undefined
@@ -104,21 +102,16 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
}) })
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input())) const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
async function load(path: string, generation: number, eager = false) { async function load(path: string, generation: number, preload = true) {
const key = path.replace(/\/+$/, "") const key = path.replace(/\/+$/, "")
setError(false) setError(false)
const absolute = absoluteTreePath(root(), key) const absolute = absoluteTreePath(root(), key)
const existing = listings.get(key)
if (existing && !eager) loads.promote(`${generation}:${key}`)
const request = const request =
existing ?? listings.get(key) ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { sdk.client.file
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.client.file
.list({ directory: absolute, path: "" }) .list({ directory: absolute, path: "" })
.then((result) => result.data ?? []) .then((result) => result.data ?? [])
.catch(() => undefined) .catch(() => undefined)
})
listings.set(key, request) listings.set(key, request)
const nodes = await request const nodes = await request
if (!activeTreeNavigation(generation, navigation)) return false if (!activeTreeNavigation(generation, navigation)) return false
@@ -128,8 +121,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
return false return false
} }
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item }))) tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
if (!eager && advanceTreePreload(advanced, key)) { if (preload && advanceTreePreload(advanced, key)) {
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true) void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false)))
} }
return true return true
} }
@@ -49,7 +49,7 @@ function uniqueRows(rows: Row[]) {
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const global = useGlobal() const global = useGlobal()
const { sync, sdk, ...serverCtx } = global.ensureServerCtx(props.server) const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server)
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
@@ -15,7 +15,6 @@ import {
treePathWithin, treePathWithin,
currentPickerSuggestions, currentPickerSuggestions,
createDirectorySearch, createDirectorySearch,
createPriorityTaskQueue,
displayPickerPath, displayPickerPath,
pickerParent, pickerParent,
pickerRoot, pickerRoot,
@@ -169,41 +168,6 @@ test("advances preloading once for every expanded directory", () => {
expect(advanceTreePreload(advanced, "repos/")).toBeTrue() expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
}) })
test("limits background tasks and prioritizes newly requested work", async () => {
const queue = createPriorityTaskQueue<void>(2)
const first = Promise.withResolvers<void>()
const second = Promise.withResolvers<void>()
const started: string[] = []
let active = 0
let maximum = 0
const task = (name: string, blocker?: Promise<void>) => async () => {
started.push(name)
active++
maximum = Math.max(maximum, active)
await blocker
active--
}
const running = [
queue.schedule("first", "background", task("first", first.promise)),
queue.schedule("second", "background", task("second", second.promise)),
queue.schedule("preload", "background", task("preload")),
queue.schedule("opened", "user", task("opened")),
]
await Promise.resolve()
expect(started).toEqual(["first", "second"])
first.resolve()
await running[0]
await Promise.resolve()
expect(started).toEqual(["first", "second", "opened"])
second.resolve()
await Promise.all(running)
expect(started).toEqual(["first", "second", "opened", "preload"])
expect(maximum).toBe(2)
})
test("clamps bridged tree wheel scrolling", () => { test("clamps bridged tree wheel scrolling", () => {
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140) expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0) expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
@@ -138,79 +138,6 @@ export function activeTreeNavigation(request: number, current: number) {
return request === current return request === current
} }
export function createPriorityTaskQueue<T>(concurrency: number) {
type Job = {
key: string
priority: "user" | "background"
promise: Promise<T>
run: () => void
}
const jobs = new Map<string, Job>()
const user: Job[] = []
const background: Job[] = []
let active = 0
const drain = () => {
while (active < concurrency) {
const job = user.pop() ?? background.shift()
if (!job) return
active++
job.run()
}
}
const schedule = (key: string, priority: Job["priority"], task: () => Promise<T>) => {
const existing = jobs.get(key)
if (existing) {
if (priority === "user") promote(key)
return existing.promise
}
const deferred = Promise.withResolvers<T>()
const job: Job = {
key,
priority,
promise: deferred.promise,
run: () => {
const complete = () => {
active--
jobs.delete(key)
drain()
}
Promise.resolve()
.then(task)
.then(
(value) => {
complete()
deferred.resolve(value)
},
(error) => {
complete()
deferred.reject(error)
},
)
},
}
jobs.set(key, job)
;(priority === "user" ? user : background).push(job)
drain()
return job.promise
}
const promote = (key: string) => {
const job = jobs.get(key)
if (!job || job.priority === "user") return
const index = background.indexOf(job)
if (index === -1) return
background.splice(index, 1)
job.priority = "user"
user.push(job)
}
return { schedule, promote }
}
export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) { export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) {
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta)) return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
} }
+1 -1
View File
@@ -11,7 +11,7 @@ export function HelpButton() {
return ( return (
<Show when={!state.dismissed}> <Show when={!state.dismissed}>
<div class="fixed bottom-4 right-4 z-50 hidden md:block"> <div class="fixed bottom-4 right-4 z-50">
<Popover <Popover
open={shown()} open={shown()}
onOpenChange={setShown} onOpenChange={setShown}
+1 -1
View File
@@ -1379,7 +1379,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
const [promptReady] = createResource( const [promptReady] = createResource(
() => prompt.ready.promise, () => prompt.ready().promise,
(p) => p, (p) => p,
) )
@@ -27,7 +27,7 @@ let variant: string | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const prompt = { const prompt = {
ready: Object.assign(() => true, { promise: Promise.resolve(true) }), ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }),
current: () => promptValue, current: () => promptValue,
cursor: () => 0, cursor: () => 0,
dirty: () => true, dirty: () => true,
@@ -185,10 +185,6 @@ beforeAll(async () => {
mock.module("@/context/server-sync", () => ({ mock.module("@/context/server-sync", () => ({
useServerSync: () => () => ({ useServerSync: () => () => ({
session: {
remember: () => undefined,
set: () => undefined,
},
child: (directory: string) => { child: (directory: string) => {
syncedDirectories.push(directory) syncedDirectories.push(directory)
storedSessions[directory] ??= [] storedSessions[directory] ??= []
@@ -56,14 +56,16 @@ const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttac
export async function sendFollowupDraft(input: FollowupSendInput) { export async function sendFollowupDraft(input: FollowupSendInput) {
const text = draftText(input.draft.prompt) const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt) const images = draftImages(input.draft.prompt)
const [, setStore] = input.serverSync.child(input.draft.sessionDirectory)
const setBusy = () => { const setBusy = () => {
if (!input.optimisticBusy) return if (!input.optimisticBusy) return
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" }) setStore("session_status", input.draft.sessionID, { type: "busy" })
} }
const setIdle = () => { const setIdle = () => {
if (!input.optimisticBusy) return if (!input.optimisticBusy) return
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" }) setStore("session_status", input.draft.sessionID, { type: "idle" })
} }
const wait = async () => { const wait = async () => {
@@ -232,7 +234,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return Promise.resolve() if (!sessionID) return Promise.resolve()
serverSync().session.set("todo", sessionID, []) serverSync().todo.set(sessionID, [])
const [, setStore] = serverSync().child(sdk().directory)
setStore("todo", sessionID, [])
input.onAbort?.() input.onAbort?.()
@@ -278,7 +282,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
const seed = (dir: string, info: Session) => { const seed = (dir: string, info: Session) => {
serverSync().session.remember(info)
const [, setStore] = serverSync().child(dir) const [, setStore] = serverSync().child(dir)
setStore("session", (list: Session[]) => { setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id) const result = Binary.search(list, info.id, (item) => item.id)
@@ -385,7 +388,12 @@ 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) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id }) if (draftID)
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}`)
} }
} }
@@ -7,8 +7,8 @@ import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion" import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/session-ui/file" import { File } from "@opencode-ai/ui/file"
import { Markdown } from "@opencode-ai/session-ui/markdown" import { Markdown } from "@opencode-ai/ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -10,7 +10,6 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { Portal } from "solid-js/web" import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -159,7 +158,6 @@ export function SessionHeader() {
const isV2 = settings.general.newLayoutDesigns const isV2 = settings.general.newLayoutDesigns
const search = settings.visibility.search const search = settings.visibility.search
const status = settings.visibility.status const status = settings.visibility.status
const isDesktop = createMediaQuery("(min-width: 768px)")
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true, finder: true,
@@ -238,7 +236,6 @@ export function SessionHeader() {
statusLabel: language.t("status.popover.trigger"), statusLabel: language.t("status.popover.trigger"),
reviewLabel: language.t("command.review.toggle"), reviewLabel: language.t("command.review.toggle"),
reviewKeybind: command.keybind("review.toggle"), reviewKeybind: command.keybind("review.toggle"),
reviewVisible: isDesktop(),
reviewOpened: view().reviewPanel.opened(), reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(), onReviewToggle: () => view().reviewPanel.toggle(),
})) }))
@@ -521,7 +518,6 @@ type SessionHeaderV2ActionsState = {
statusLabel: string statusLabel: string
reviewLabel: string reviewLabel: string
reviewKeybind: string reviewKeybind: string
reviewVisible: boolean
reviewOpened: boolean reviewOpened: boolean
onReviewToggle: () => void onReviewToggle: () => void
} }
@@ -534,7 +530,6 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
<StatusPopoverV2 /> <StatusPopoverV2 />
</Tooltip> </Tooltip>
</Show> </Show>
<Show when={props.state.reviewVisible}>
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}> <TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
<IconButtonV2 <IconButtonV2
type="button" type="button"
@@ -549,7 +544,6 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
icon={<IconV2 name="sidebar-right" />} icon={<IconV2 name="sidebar-right" />}
/> />
</TooltipKeybind> </TooltipKeybind>
</Show>
</div> </div>
) )
} }
@@ -335,6 +335,18 @@ export const SettingsGeneral: Component = () => {
</div> </div>
</SettingsRow> </SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showSessionProgressBar.title")}
description={language.t("settings.general.row.showSessionProgressBar.description")}
>
<div data-action="settings-show-session-progress-bar">
<Switch
checked={settings.general.showSessionProgressBar()}
onChange={(checked) => settings.general.setShowSessionProgressBar(checked)}
/>
</div>
</SettingsRow>
<SettingsRow <SettingsRow
title={language.t("settings.general.row.newLayoutDesigns.title")} title={language.t("settings.general.row.newLayoutDesigns.title")}
description={language.t("settings.general.row.newLayoutDesigns.description")} description={language.t("settings.general.row.newLayoutDesigns.description")}
@@ -26,7 +26,7 @@ export function SettingsServerScope(props: ParentProps) {
function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) { function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) {
const global = useGlobal() const global = useGlobal()
const serverCtx = () => global.ensureServerCtx(props.server) const serverCtx = () => global.createServerCtx(props.server)
return ( return (
<QueryClientProvider client={serverCtx().queryClient}> <QueryClientProvider client={serverCtx().queryClient}>
@@ -1,5 +1,4 @@
import { Component, Show, createMemo, createResource, onMount } from "solid-js" import { Component, Show, createMemo, createResource, onMount } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { Switch } from "@opencode-ai/ui/v2/switch-v2"
@@ -90,7 +89,6 @@ export const SettingsGeneralV2: Component = () => {
const dialog = useDialog() const dialog = useDialog()
const params = useParams() const params = useParams()
const settings = useSettings() const settings = useSettings()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction() const updater = useUpdaterAction()
@@ -318,6 +316,18 @@ export const SettingsGeneralV2: Component = () => {
</div> </div>
</SettingsRowV2> </SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showSessionProgressBar.title")}
description={language.t("settings.general.row.showSessionProgressBar.description")}
>
<div data-action="settings-show-session-progress-bar">
<Switch
checked={settings.general.showSessionProgressBar()}
onChange={(checked) => settings.general.setShowSessionProgressBar(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2 <SettingsRowV2
title={language.t("settings.general.row.newLayoutDesigns.title")} title={language.t("settings.general.row.newLayoutDesigns.title")}
description={language.t("settings.general.row.newLayoutDesigns.description")} description={language.t("settings.general.row.newLayoutDesigns.description")}
@@ -335,20 +345,6 @@ export const SettingsGeneralV2: Component = () => {
/> />
</div> </div>
</SettingsRowV2> </SettingsRowV2>
<Show when={mobile()}>
<SettingsRowV2
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
description={language.t("settings.general.row.mobileTitlebarBottom.description")}
>
<div data-action="settings-mobile-titlebar-bottom">
<Switch
checked={settings.general.mobileTitlebarPosition() === "bottom"}
onChange={(checked) => settings.general.setMobileTitlebarPosition(checked ? "bottom" : "top")}
/>
</div>
</SettingsRowV2>
</Show>
</SettingsListV2> </SettingsListV2>
</div> </div>
) )
@@ -1,3 +1,4 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Tag } from "@opencode-ai/ui/v2/badge-v2" import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
@@ -13,7 +14,7 @@ import { ServerConnection, serverName } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server" import { useServerManagementController } from "../dialog-select-server"
import { DialogServerV2 } from "./dialog-server-v2" import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings" import { isWslServer, useFilteredWslServers, WslAddServerButton, WslServerSettings } from "@/wsl/settings"
import "./settings-v2.css" import "./settings-v2.css"
export const SettingsServersV2: Component = () => { export const SettingsServersV2: Component = () => {
@@ -54,7 +55,10 @@ export const SettingsServersV2: Component = () => {
> >
<div class="settings-v2-tab-header-row"> <div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2> <h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
<AddServerMenu onAddServer={openAdd} /> <ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
{language.t("dialog.server.add.button")}
</ButtonV2>
<WslAddServerButton />
</div> </div>
<Show when={showSearch()}> <Show when={showSearch()}>
<div class="settings-v2-tab-search"> <div class="settings-v2-tab-search">
@@ -167,15 +167,6 @@
background-color: var(--v2-background-bg-layer-01); background-color: var(--v2-background-bg-layer-01);
} }
@media (max-width: 639px) {
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
[data-slot="tabs-v2-list"] {
width: 144px;
min-width: 144px;
padding-inline: 8px;
}
}
.settings-v2-nav-footer { .settings-v2-nav-footer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+97 -147
View File
@@ -2,7 +2,6 @@ import {
createEffect, createEffect,
createMemo, createMemo,
createResource, createResource,
createRoot,
createSignal, createSignal,
For, For,
Match, Match,
@@ -23,26 +22,26 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { LayoutRoute, useLayout } from "@/context/layout" import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" 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 { projectForSession } from "@/pages/layout/helpers" import { useServerSync } from "@/context/server-sync"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { base64Encode } from "@opencode-ai/core/util/encode"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMediaQuery } from "@solid-primitives/media"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events" import { 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 } from "@/context/tabs" import { tabHref, useTabs, type Tab } from "@/context/tabs"
import "./titlebar.css" import "./titlebar.css"
import { Session } from "@opencode-ai/sdk/v2"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createTabPromptState } from "@/context/prompt"
type TauriDesktopWindow = { type TauriDesktopWindow = {
startDragging?: () => Promise<void> startDragging?: () => Promise<void>
@@ -88,8 +87,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const location = useLocation() const location = useLocation()
const params = useParams() const params = useParams()
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns()) const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
const mobile = createMediaQuery("(max-width: 767px)")
const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom")
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
@@ -238,11 +235,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
"shrink-0 relative flex flex-row": true, "shrink-0 relative flex flex-row": true,
"h-9 bg-v2-background-bg-deep overflow-visible": useV2Titlebar(), "h-9 bg-v2-background-bg-deep overflow-visible": useV2Titlebar(),
"h-10 bg-background-base overflow-hidden": !useV2Titlebar(), "h-10 bg-background-base overflow-hidden": !useV2Titlebar(),
"order-last": bottom(),
}} }}
style={{ style={{
"min-height": minHeight(), "min-height": minHeight(),
"padding-left": mac() && !mobile() ? `${84 / zoom()}px` : 0, "padding-left": mac() ? `${84 / zoom()}px` : 0,
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined, width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": electronWindows() "max-width": electronWindows()
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
@@ -256,28 +252,22 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<Switch> <Switch>
<Match when={useV2Titlebar()}> <Match when={useV2Titlebar()}>
{(_) => { {(_) => {
const serverSync = useServerSync()
const navigate = useNavigate() const navigate = useNavigate()
const layout = useLayout() const layout = useLayout()
const global = useGlobal()
const newSessionHref = () => {
if (params.dir) return `/${params.dir}/session`
const project = layout.projects.list()[0]
if (!project) return "/"
return `/${base64Encode(project.worktree)}/session`
}
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()
if (route.type !== "session") return undefined
const conn = global.servers
.list()
.find((item) => ServerConnection.key(item) === (route.server ?? server.key))
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
},
({ route, sdk }) =>
sdk.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
@@ -290,9 +280,10 @@ 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 s = session() const sync = serverSync().createDirSyncContext(route.dir)
if (s?.parentID) { const session = sync.session.get(route.sessionId)
const parentID = s.parentID if (session?.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,
) )
@@ -313,10 +304,15 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
} }
if (route.type === "session") { if (route.type === "session") {
const s = session() const sync = serverSync().createDirSyncContext(route.dir)
if (!s) return const session = sync.session.get(route.sessionId)
const sessionId = s.parentID ?? s.id if (!session) return
const next = { server: route.server ?? server.key, sessionId } const sessionId = session.parentID ?? session.id
const next = {
server: route.server ?? server.key,
dirBase64: route.dirBase64,
sessionId,
}
tabsStoreActions.addSessionTab(next) tabsStoreActions.addSessionTab(next)
} }
}) })
@@ -327,28 +323,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
tabsStoreActions.removeSessions(detail) tabsStoreActions.removeSessions(detail)
}) })
const openNewTab = () => { const openNewTab = () => navigate(newSessionHref())
const route = layout.route()
const activeSession = session()
if (route.type === "session" && activeSession) {
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
return
}
const current = layout.projects.list()[0]
if (current) {
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
return
}
const fallback = global.servers.list().flatMap((conn) => {
const project = global.ensureServerCtx(conn).projects.list()[0]
return project ? [{ server: ServerConnection.key(conn), project }] : []
})[0]
if (!fallback) return
tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "")
}
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() }) const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
command.register("titlebar-home", () => [ command.register("titlebar-home", () => [
@@ -418,6 +393,22 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
if (next) tabs.select(next) if (next) tabs.select(next)
}, },
}, },
...Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
return {
id: `tab.${number}`,
category: "tab",
title: "",
keybind: `mod+${number}`,
disabled: layout.projects.list().length <= index,
hidden: true,
onSelect: () => {
const tab = tabsStore[index]
if (tab) tabs.select(tab)
},
}
}),
].filter((v) => v !== undefined) ].filter((v) => v !== undefined)
}) })
@@ -430,12 +421,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return ( return (
<div <div
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3" class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 pr-3 pt-2"
classList={{ classList={{
"pt-2": !bottom(), "pl-2": mac(),
"pb-2": bottom(), "pl-4": !mac(),
"md:pl-2": mac(),
"md:pl-4": !mac(),
}} }}
> >
<ChannelIndicator /> <ChannelIndicator />
@@ -481,7 +470,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<For each={tabsStore}> <For each={tabsStore}>
{(tab, i) => { {(tab, i) => {
let ref!: HTMLDivElement let ref!: HTMLDivElement
useTabShortcut(i, () => tabs.select(tab))
const divider = () => const divider = () =>
i() !== 0 && ( i() !== 0 && (
@@ -507,65 +495,15 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
) )
} }
const serverCtx = createMemo(() => {
const conn = server.list.find((item) => ServerConnection.key(item) === tab.server)
return conn ? global.ensureServerCtx(conn) : undefined
})
const sdk = createMemo(() => serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => serverCtx()?.sync.session.peek(tab.sessionId))
const [loadedSession] = createResource(
() => {
const id = tab.sessionId
const ctx = serverCtx()
return ctx ? { id, ctx } : null
},
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
)
const session = createMemo(() => cachedSession() ?? loadedSession())
let prefetched = false
createEffect(() => {
const ctx = serverCtx()
const sess = session()
if (!ctx || !sess || prefetched) return
prefetched = true
createRoot((dispose) => {
try {
void ctx.sync
.ensureDirSyncContext(sess.directory)
.session.sync(sess.id)
.catch(() => {})
.finally(dispose)
} catch {
dispose()
}
})
})
createEffect(() => {
if (tab.type !== "session") return
const _sdk = sdk()
if (!_sdk) return
const sess = session()
if (!sess) return
createTabPromptState(tabs, tab, _sdk.scope, {
dir: base64Encode(sess.directory),
id: sess.id,
})
})
return ( return (
<> <>
{divider()} {divider()}
<Show when={session()}>
{(session) => (
<TabNavItem <TabNavItem
ref={ref} ref={ref}
href={tabHref(tab)} href={tabHref(tab)}
server={tab.server} server={tab.server}
directory={decode64(tab.dirBase64)!}
sessionId={tab.sessionId} sessionId={tab.sessionId}
session={session()}
onNavigate={() => { onNavigate={() => {
tabs.select(tab) tabs.select(tab)
@@ -576,8 +514,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
activeServer={tab.server === server.key} activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()} forceTruncate={tabsAreOverflowing()}
/> />
)}
</Show>
</> </>
) )
}} }}
@@ -627,7 +563,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
size="large" size="large"
class="shrink-0" class="shrink-0"
icon={<IconV2 name="plus" />} icon={<IconV2 name="plus" />}
onClick={openNewTab} as="a"
href={newSessionHref()}
aria-label={language.t("command.session.new")} aria-label={language.t("command.session.new")}
/> />
</Show> </Show>
@@ -852,30 +789,11 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
) )
} }
function useTabShortcut(index: () => number, onSelect: () => void) {
const command = useCommand()
command.register(() => {
const number = index() + 1
if (number > 9) return []
return [
{
id: `tab.${number}`,
category: "tab",
title: "",
keybind: `mod+${number}`,
hidden: true,
onSelect,
},
]
})
}
function TabNavItem(props: { 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
@@ -883,31 +801,43 @@ 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.ensureServerCtx(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
ref={props.ref} ref={props.ref}
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active} data-active={props.active}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== 1) return if (event.button !== 1) return
closeTab(event) closeTab(event)
}} }}
> >
<Show when={props.session}> <Show when={session.latest}>
{(session) => { {(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? [])) const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
@@ -921,9 +851,9 @@ function TabNavItem(props: {
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base" class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
> >
<span data-slot="project-avatar-slot"> <span data-slot="project-avatar-slot">
<SessionTabAvatar <ProjectTabAvatar
project={project()} project={project()}
directory={session().directory} directory={props.directory}
sessionId={session().id} sessionId={session().id}
activeServer={props.activeServer} activeServer={props.activeServer}
/> />
@@ -957,6 +887,26 @@ function TabNavItem(props: {
) )
} }
function ProjectTabAvatar(props: {
project?: LocalProject
directory: string
sessionId: string
activeServer: boolean
}) {
const directory = () => props.directory
const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
return (
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
loading={state.loading()}
/>
)
}
function DraftTabItem(props: { function DraftTabItem(props: {
ref?: HTMLDivElement ref?: HTMLDivElement
href: string href: string
@@ -974,7 +924,7 @@ function DraftTabItem(props: {
<div <div
ref={props.ref} ref={props.ref}
data-active={props.active} data-active={props.active}
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]" class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== 1) return if (event.button !== 1) return
closeTab(event) closeTab(event)
@@ -1019,7 +969,7 @@ function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: s
return ( return (
<div <div
ref={props.ref} ref={props.ref}
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]" class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== 1) return if (event.button !== 1) return
closeTab(event) closeTab(event)
+1 -4
View File
@@ -2,14 +2,12 @@ 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
@@ -204,7 +202,6 @@ 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) => {
@@ -231,7 +228,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
return cache.get(key).value return cache.get(key).value
} }
const session = createMemo(() => load(base64Encode(sdk().directory), params.id)) const session = createMemo(() => load(params.dir!, params.id))
return { return {
ready: () => session().ready(), ready: () => session().ready(),
+545 -75
View File
@@ -1,23 +1,175 @@
import { batch, createMemo } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import {
clearSessionPrefetch,
getSessionPrefetch,
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { createMemo } from "solid-js" import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { produce, reconcile, type SetStoreFunction } from "solid-js/store" import { diffs as list, message as clean } from "@/utils/diffs"
import type { createServerSdkContext } from "./server-sdk" import { type createServerSdkContext } from "./server-sdk"
import type { createServerSyncContextInner } from "./server-sync" import { type createServerSyncContextInner } from "./server-sync"
import type { State } from "./global-sync/types"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key)
if (pending) return pending
const promise = task().finally(() => {
map.delete(key)
})
map.set(key, promise)
return promise
}
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const sessionFields = new Set([
"session_status", const isNotFound = (error: unknown) =>
"session_working", error instanceof Error &&
"session_diff", typeof error.cause === "object" &&
"todo", error.cause !== null &&
"permission", (error.cause as { status?: unknown }).status === 404
"question",
"message", function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
"part", const map = new Map(a.map((item) => [item.id, item] as const))
"part_text_accum_delta", for (const item of b) map.set(item.id, item)
]) return [...map.values()].sort((x, y) => cmp(x.id, y.id))
}
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
}
type OptimisticAddInput = {
sessionID: string
message: Message
parts: Part[]
}
type OptimisticRemoveInput = {
sessionID: string
messageID: string
}
type OptimisticItem = {
message: Message
parts: Part[]
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
let changed = false
for (const part of want) {
const result = Binary.search(next, part.id, (item) => item.id)
if (result.found) continue
next.splice(result.index, 0, part)
changed = true
}
if (!changed) return parts
return next
}
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, mergeParts(current, item.parts))
}
return {
cursor: page.cursor,
complete: page.complete,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
confirmed,
}
}
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.message.id, (m) => m.id)
messages.splice(result.index, 0, input.message)
} else {
draft.message[input.sessionID] = [input.message]
}
draft.part[input.message.id] = sortParts(input.parts)
}
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
delete draft.part[input.messageID]
}
function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return [input.message]
const result = Binary.search(messages, input.message.id, (m) => m.id)
const next = [...messages]
next.splice(result.index, 0, input.message)
return next
})
setStore("part", input.message.id, sortParts(input.parts))
}
function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return messages
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (!result.found) return messages
const next = [...messages]
next.splice(result.index, 1)
return next
})
setStore("part", (part: Record<string, Part[] | undefined>) => {
if (!(input.messageID in part)) return part
const next = { ...part }
delete next[input.messageID]
return next
})
}
export const createDirSyncContext = ( export const createDirSyncContext = (
directory: string, directory: string,
@@ -25,42 +177,210 @@ export const createDirSyncContext = (
serverSDK: ReturnType<typeof createServerSdkContext>, serverSDK: ReturnType<typeof createServerSdkContext>,
) => { ) => {
const client = serverSDK.createClient({ directory, throwOnError: true }) const client = serverSDK.createClient({ directory, throwOnError: true })
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const data = new Proxy({} as State, {
get(_, property: keyof State) {
if (property === "session_working") return serverSync.session.data.session_working.bind(serverSync.session.data)
if (sessionFields.has(property)) return serverSync.session.data[property as keyof typeof serverSync.session.data]
return current()[0][property]
},
})
const set = ((...input: unknown[]) => {
if (typeof input[0] === "string" && sessionFields.has(input[0])) {
return (serverSync.session.set as (...args: unknown[]) => unknown)(...input)
}
const result = (current()[1] as (...args: unknown[]) => unknown)(...input)
if (input[0] === "session") current()[0].session.forEach(serverSync.session.remember)
return result
}) as SetStoreFunction<State>
const index = (sessionID: string) => { type Child = ReturnType<(typeof serverSync)["child"]>
const session = serverSync.session.get(sessionID) type Setter = Child[1]
if (!session || session.directory !== directory) return
const [store, setStore] = current() const current = createMemo(() => serverSync.child(directory, { mcp: true }))
const result = Binary.search(store.session, session.id, (item) => item.id) const target = (directory?: string) => {
if (result.found) { if (!directory || directory === directory) return current()
setStore("session", result.index, reconcile(session)) return serverSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80
const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const maxDirs = 30
const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
})
const getSession = (sessionID: string) => {
const store = current()[0]
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
}
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
const key = keyFor(directory, sessionID)
const list = optimistic.get(key)
if (list) {
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
return return
} }
setStore( optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
"session", }
produce((draft) => void draft.splice(result.index, 0, session)),
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
const key = keyFor(directory, sessionID)
if (!messageID) {
optimistic.delete(key)
return
}
const list = optimistic.get(key)
if (!list) return
list.delete(messageID)
if (list.size === 0) optimistic.delete(key)
}
const getOptimistic = (directory: string, sessionID: string) => [
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
]
const seenFor = (directory: string) => {
const existing = seen.get(directory)
if (existing) {
seen.delete(directory)
seen.set(directory, existing)
return existing
}
const created = new Set<string>()
seen.set(directory, created)
while (seen.size > maxDirs) {
const first = seen.keys().next().value
if (!first) break
const stale = [...(seen.get(first) ?? [])]
seen.delete(first)
const [, setStore] = serverSync.child(first, { bootstrap: false })
evict(first, setStore, stale)
}
return created
}
const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
for (const sessionID of sessionIDs) {
clearOptimistic(directory, sessionID)
}
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
const key = keyFor(directory, sessionID)
delete draft.limit[key]
delete draft.cursor[key]
delete draft.complete[key]
delete draft.loading[key]
}
}),
) )
} }
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
clearSessionPrefetch(serverSDK.scope, directory, sessionIDs)
for (const sessionID of sessionIDs) {
serverSync.todo.set(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, sessionIDs)
}),
)
clearMeta(directory, sessionIDs)
}
const touch = (directory: string, setStore: Setter, sessionID: string) => {
const stale = pickSessionCacheEvictions({
seen: seenFor(directory),
keep: sessionID,
limit: SESSION_CACHE_LIMIT,
})
evict(directory, setStore, stale)
}
const fetchMessages = async (input: { client: typeof client; sessionID: string; limit: number; before?: string }) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return { return {
data, session,
set, part,
cursor,
complete: !cursor,
}
}
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
const loadMessages = async (input: {
directory: string
client: typeof client
setStore: Setter
sessionID: string
limit: number
before?: string
mode?: "replace" | "prepend"
}) => {
const key = keyFor(input.directory, input.sessionID)
if (meta.loading[key]) return
setMeta("loading", key, true)
await fetchMessages(input)
.then((page) => {
if (!tracked(input.directory, input.sessionID)) return
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID)
}
const [store] = serverSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) {
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
if (filtered.length) input.setStore("part", p.id, filtered)
}
setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete)
setSessionPrefetch({
scope: serverSDK.scope,
directory: input.directory,
sessionID: input.sessionID,
limit: message.length,
cursor: next.cursor,
complete: next.complete,
})
})
})
.catch((error) => {
if (isNotFound(error) && !tracked(input.directory, input.sessionID)) return
throw error
})
.finally(() => {
setMeta(
produce((draft) => {
if (!tracked(input.directory, input.sessionID)) {
delete draft.loading[key]
return
}
draft.loading[key] = false
}),
)
})
}
return {
get data() {
return current()[0]
},
get set(): Setter {
return current()[1]
},
get status() { get status() {
return current()[0].status return current()[0].status
}, },
@@ -69,20 +389,24 @@ export const createDirSyncContext = (
}, },
get project() { get project() {
const store = current()[0] const store = current()[0]
const match = Binary.search(serverSync.data.project, store.project, (project) => project.id) const match = Binary.search(serverSync.data.project, store.project, (p) => p.id)
if (match.found) return serverSync.data.project[match.index] if (match.found) return serverSync.data.project[match.index]
return undefined
}, },
session: { session: {
get(sessionID: string) { get: getSession,
const session = serverSync.session.get(sessionID)
if (session?.directory === directory) return session
},
optimistic: { optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) { add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
serverSync.session.optimistic.add(input) const _directory = input.directory ?? directory
const [, setStore] = target(input.directory)
setOptimistic(_directory, input.sessionID, { message: input.message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
}, },
remove(input: { directory?: string; sessionID: string; messageID: string }) { remove(input: { directory?: string; sessionID: string; messageID: string }) {
serverSync.session.optimistic.remove(input) const _directory = input.directory ?? directory
const [, setStore] = target(input.directory)
clearOptimistic(_directory, input.sessionID, input.messageID)
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
}, },
}, },
addOptimisticMessage(input: { addOptimisticMessage(input: {
@@ -93,48 +417,194 @@ export const createDirSyncContext = (
model: { providerID: string; modelID: string } model: { providerID: string; modelID: string }
variant?: string variant?: string
}) { }) {
serverSync.session.optimistic.add({ const message: Message = {
sessionID: input.sessionID,
message: {
id: input.messageID, id: input.messageID,
sessionID: input.sessionID, sessionID: input.sessionID,
role: "user", role: "user",
time: { created: Date.now() }, time: { created: Date.now() },
agent: input.agent, agent: input.agent,
model: { ...input.model, variant: input.variant }, model: { ...input.model, variant: input.variant },
}, }
const [, setStore] = target()
setOptimistic(directory, input.sessionID, { message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
sessionID: input.sessionID,
message,
parts: input.parts, parts: input.parts,
}) })
}, },
async sync(sessionID: string, options?: { force?: boolean }) { async sync(sessionID: string, opts?: { force?: boolean }) {
await serverSync.session.sync(sessionID, options) const [store, setStore] = serverSync.child(directory)
index(sessionID) const key = keyFor(directory, sessionID)
touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(serverSDK.scope, directory, sessionID)
if (pending) {
await pending
const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
}
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
: retry(() => client.session.get({ sessionID }))
.then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
.catch((error) => {
if (isNotFound(error) && !tracked(directory, sessionID)) return
throw error
})
const messagesReq =
cached && !opts?.force
? Promise.resolve()
: loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
await Promise.all([sessionReq, messagesReq])
})
}, },
diff: serverSync.session.diff, async diff(sessionID: string, opts?: { force?: boolean }) {
todo: serverSync.session.todo, const [store, setStore] = serverSync.child(directory)
history: serverSync.session.history, touch(directory, setStore, sessionID)
evict(sessionID: string) { if (store.session_diff[sessionID] !== undefined && !opts?.force) return
serverSync.session.evict(sessionID)
const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
}),
)
},
async todo(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = serverSync.child(directory)
touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = serverSync.data.session_todo[sessionID]
if (existing !== undefined) {
if (cached === undefined) {
serverSync.todo.set(sessionID, existing)
}
if (!opts?.force) return
}
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
if (!tracked(directory, sessionID)) return
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
serverSync.todo.set(sessionID, list)
}),
)
},
history: {
more(sessionID: string) {
const store = current()[0]
const key = keyFor(directory, sessionID)
if (store.message[sessionID] === undefined) return false
if (meta.limit[key] === undefined) return false
if (meta.complete[key]) return false
return !!meta.cursor[key]
},
loading(sessionID: string) {
const key = keyFor(directory, sessionID)
return meta.loading[key] ?? false
},
async loadMore(sessionID: string, count?: number) {
const [, setStore] = serverSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? historyMessagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]
if (!before) return
await loadMessages({
directory,
client,
setStore,
sessionID,
limit: step,
before,
mode: "prepend",
})
},
},
evict(sessionID: string, _directory = directory) {
const [, setStore] = serverSync.child(_directory)
seenFor(_directory).delete(sessionID)
evict(_directory, setStore, [sessionID])
}, },
fetch: async (count = 10) => { fetch: async (count = 10) => {
const [store, setStore] = current() const [store, setStore] = serverSync.child(directory)
setStore("limit", (value) => value + count) setStore("limit", (x) => x + count)
const response = await client.session.list() await client.session.list().then((x) => {
const sessions = (response.data ?? []) const sessions = (x.data ?? [])
.filter((session) => !!session?.id) .filter((s) => !!s?.id)
.sort((a, b) => cmp(a.id, b.id)) .sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit) .slice(0, store.limit)
sessions.forEach(serverSync.session.remember)
setStore("session", reconcile(sessions, { key: "id" })) setStore("session", reconcile(sessions, { key: "id" }))
})
}, },
more: createMemo(() => current()[0].session.length >= current()[0].limit), more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => { archive: async (sessionID: string) => {
await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } }) const [, setStore] = serverSync.child(directory)
current()[1]( await client.session.update({ sessionID, time: { archived: Date.now() } })
"session", setStore(
produce((draft) => { produce((draft) => {
const match = Binary.search(draft, sessionID, (session) => session.id) const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.splice(match.index, 1) if (match.found) draft.session.splice(match.index, 1)
}), }),
) )
}, },
+1 -2
View File
@@ -3,7 +3,6 @@ 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"
@@ -66,7 +65,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(base64Encode(sdk().directory), params.id)), SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)),
) )
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query" import { QueryClient } from "@tanstack/solid-query"
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client" import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap" import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
@@ -7,25 +7,28 @@ import type {
ProviderAuthResponse, ProviderAuthResponse,
QuestionRequest, QuestionRequest,
Session, Session,
Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/core/util/retry"
import { batch } from "solid-js" import { batch } from "solid-js"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import type { ServerSession } from "../server-session"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query" import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery } from "../server-sync" import { loadMcpQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope" import { ScopedKey, type ServerScope } from "@/utils/server-scope"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
path: Path path: Path
project: Project[] project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: NormalizedProviderListResponse provider: NormalizedProviderListResponse
provider_auth: ProviderAuthResponse provider_auth: ProviderAuthResponse
config: Config config: Config
@@ -212,7 +215,6 @@ export async function bootstrapDirectory(input: {
provider: NormalizedProviderListResponse provider: NormalizedProviderListResponse
} }
queryClient: QueryClient queryClient: QueryClient
session?: ServerSession
}) { }) {
const loading = input.store.status !== "complete" const loading = input.store.status !== "complete"
const seededProject = projectID(input.directory, input.global.project) const seededProject = projectID(input.directory, input.global.project)
@@ -236,30 +238,7 @@ export async function bootstrapDirectory(input: {
.then((data) => input.setStore("agent", data)), .then((data) => input.setStore("agent", data)),
() => () =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))), retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
() => () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
retry(() =>
input.sdk.session.status().then(async (x) => {
if (input.session) {
const statuses = x.data ?? {}
await Promise.all(
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
)
input.session.set(
"session_status",
produce((draft) => {
for (const sessionID of Object.keys(draft)) {
if (statuses[sessionID]) continue
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
}
}),
)
for (const [sessionID, status] of Object.entries(statuses)) {
input.session.set("session_status", sessionID, reconcile(status))
}
}
if (!input.session) input.setStore("session_status", x.data!)
}),
),
!seededProject && !seededProject &&
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))), (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
!seededPath && !seededPath &&
@@ -284,25 +263,21 @@ export async function bootstrapDirectory(input: {
const grouped = groupBySession( const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
) )
const warm = input.session return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
return warm.then(() =>
batch(() => { batch(() => {
const current = input.session?.data.permission ?? input.store.permission for (const sessionID of Object.keys(input.store.permission)) {
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.directory !== input.directory) continue input.setStore("permission", sessionID, [])
if (input.session) input.session.set("permission", sessionID, [])
if (!input.session) input.setStore("permission", sessionID, [])
} }
for (const [sessionID, permissions] of Object.entries(grouped)) { for (const [sessionID, permissions] of Object.entries(grouped)) {
const value = reconcile( input.setStore(
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" }, { key: "id" },
),
) )
if (input.session) input.session.set("permission", sessionID, value)
if (!input.session) input.setStore("permission", sessionID, value)
} }
}), }),
) )
@@ -313,25 +288,21 @@ export async function bootstrapDirectory(input: {
input.sdk.question.list().then((x) => { input.sdk.question.list().then((x) => {
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id) const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
const warm = input.session return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
return warm.then(() =>
batch(() => { batch(() => {
const current = input.session?.data.question ?? input.store.question for (const sessionID of Object.keys(input.store.question)) {
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.directory !== input.directory) continue input.setStore("question", sessionID, [])
if (input.session) input.session.set("question", sessionID, [])
if (!input.session) input.setStore("question", sessionID, [])
} }
for (const [sessionID, questions] of Object.entries(grouped)) { for (const [sessionID, questions] of Object.entries(grouped)) {
const value = reconcile( input.setStore(
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" }, { key: "id" },
),
) )
if (input.session) input.session.set("question", sessionID, value)
if (!input.session) input.setStore("question", sessionID, value)
} }
}), }),
) )
@@ -1,7 +1,7 @@
import { beforeAll, describe, expect, mock, test } from "bun:test" import { beforeAll, describe, expect, mock, test } from "bun:test"
import { createRoot, getOwner, type Owner } from "solid-js" import { createRoot, getOwner, type Owner } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { State } from "./types" import type { State } from "./types"
import type { QueryOptionsApi } from "../server-sync" import type { QueryOptionsApi } from "../server-sync"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
@@ -17,7 +17,7 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQuery } from "@tanstack/solid-query" import { useQuery } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync" import { QueryOptionsApi } from "../server-sync"
import { directoryKey, type DirectoryKey } from "./utils" import { directoryKey, type DirectoryKey } from "./utils"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
export function createChildStoreManager(input: { export function createChildStoreManager(input: {
@@ -17,21 +17,6 @@ import { dropSessionCaches } from "./session-cache"
import { diffs as list, message as clean } from "@/utils/diffs" import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const SESSION_CONTENT_EVENTS = new Set([
"session.diff",
"todo.updated",
"session.status",
"message.updated",
"message.removed",
"message.part.updated",
"message.part.removed",
"message.part.delta",
"permission.asked",
"permission.replied",
"question.asked",
"question.replied",
"question.rejected",
])
export function applyGlobalEvent(input: { export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown } event: { type: string; properties?: unknown }
@@ -115,11 +100,8 @@ export function applyDirectoryEvent(input: {
vcsCache?: VcsCache vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
retainedLimit?: number retainedLimit?: number
sessionContent?: boolean
permission?: State["permission"]
}) { }) {
const event = input.event const event = input.event
if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0) const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
switch (event.type) { switch (event.type) {
case "server.instance.disposed": { case "server.instance.disposed": {
@@ -135,7 +117,7 @@ export function applyDirectoryEvent(input: {
} }
const next = input.store.session.slice() const next = input.store.session.slice()
next.splice(result.index, 0, info) next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission }) const trimmed = trimSessions(next, { limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" })) input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1) if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
@@ -165,7 +147,7 @@ export function applyDirectoryEvent(input: {
} }
const next = input.store.session.slice() const next = input.store.session.slice()
next.splice(result.index, 0, info) next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission }) const trimmed = trimSessions(next, { limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" })) input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
break break
@@ -0,0 +1,139 @@
import { describe, expect, test } from "bun:test"
import {
clearSessionPrefetch,
clearSessionPrefetchDirectory,
getSessionPrefetch,
runSessionPrefetch,
setSessionPrefetch,
shouldSkipSessionPrefetch,
} from "./session-prefetch"
import { ServerScope } from "@/utils/server-scope"
const scope = ServerScope.local
describe("session prefetch", () => {
test("stores and clears message metadata by directory", () => {
clearSessionPrefetch(scope, "/tmp/a", ["ses_1"])
clearSessionPrefetch(scope, "/tmp/b", ["ses_1"])
setSessionPrefetch({
directory: "/tmp/a",
scope,
sessionID: "ses_1",
limit: 200,
cursor: "abc",
complete: false,
at: 123,
})
expect(getSessionPrefetch(scope, "/tmp/a", "ses_1")).toEqual({
limit: 200,
cursor: "abc",
complete: false,
at: 123,
})
expect(getSessionPrefetch(scope, "/tmp/b", "ses_1")).toBeUndefined()
clearSessionPrefetch(scope, "/tmp/a", ["ses_1"])
expect(getSessionPrefetch(scope, "/tmp/a", "ses_1")).toBeUndefined()
})
test("dedupes inflight work", async () => {
clearSessionPrefetch(scope, "/tmp/c", ["ses_2"])
let calls = 0
const run = () =>
runSessionPrefetch({
directory: "/tmp/c",
scope,
sessionID: "ses_2",
task: async () => {
calls += 1
return { limit: 100, cursor: "next", complete: true, at: 456 }
},
})
const [a, b] = await Promise.all([run(), run()])
expect(calls).toBe(1)
expect(a).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
expect(b).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
})
test("clears a whole directory", () => {
setSessionPrefetch({
scope,
directory: "/tmp/d",
sessionID: "ses_1",
limit: 10,
cursor: "a",
complete: true,
at: 1,
})
setSessionPrefetch({
scope,
directory: "/tmp/d",
sessionID: "ses_2",
limit: 20,
cursor: "b",
complete: false,
at: 2,
})
setSessionPrefetch({
scope,
directory: "/tmp/e",
sessionID: "ses_1",
limit: 30,
cursor: "c",
complete: true,
at: 3,
})
clearSessionPrefetchDirectory(scope, "/tmp/d")
expect(getSessionPrefetch(scope, "/tmp/d", "ses_1")).toBeUndefined()
expect(getSessionPrefetch(scope, "/tmp/d", "ses_2")).toBeUndefined()
expect(getSessionPrefetch(scope, "/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
})
test("isolates identical directories and sessions by server scope", () => {
const remote = "https://debian.example" as ServerScope
setSessionPrefetch({ scope, directory: "/repo", sessionID: "ses_1", limit: 10, complete: true, at: 1 })
setSessionPrefetch({ scope: remote, directory: "/repo", sessionID: "ses_1", limit: 20, complete: true, at: 2 })
expect(getSessionPrefetch(scope, "/repo", "ses_1")?.limit).toBe(10)
expect(getSessionPrefetch(remote, "/repo", "ses_1")?.limit).toBe(20)
})
test("refreshes stale first-page prefetched history", () => {
expect(
shouldSkipSessionPrefetch({
message: true,
info: { limit: 200, cursor: "x", complete: false, at: 1 },
chunk: 200,
now: 1 + 15_001,
}),
).toBe(false)
})
test("keeps deeper or complete history cached", () => {
expect(
shouldSkipSessionPrefetch({
message: true,
info: { limit: 400, cursor: "x", complete: false, at: 1 },
chunk: 200,
now: 1 + 15_001,
}),
).toBe(true)
expect(
shouldSkipSessionPrefetch({
message: true,
info: { limit: 120, complete: true, at: 1 },
chunk: 200,
now: 1 + 15_001,
}),
).toBe(true)
})
})
@@ -0,0 +1,107 @@
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
const key = (scope: ServerScope, directory: string, sessionID: string) => ScopedKey.from(scope, directory, sessionID)
export const SESSION_PREFETCH_TTL = 15_000
type Meta = {
limit: number
cursor?: string
complete: boolean
at: number
}
export function shouldSkipSessionPrefetch(input: { message: boolean; info?: Meta; chunk: number; now?: number }) {
if (input.message) {
if (!input.info) return true
if (input.info.complete) return true
if (input.info.limit > input.chunk) return true
} else {
if (!input.info) return false
}
return (input.now ?? Date.now()) - input.info.at < SESSION_PREFETCH_TTL
}
const cache = new Map<string, Meta>()
const inflight = new Map<string, Promise<Meta | undefined>>()
const rev = new Map<string, number>()
const version = (id: string) => rev.get(id) ?? 0
export function getSessionPrefetch(scope: ServerScope, directory: string, sessionID: string) {
return cache.get(key(scope, directory, sessionID))
}
export function getSessionPrefetchPromise(scope: ServerScope, directory: string, sessionID: string) {
return inflight.get(key(scope, directory, sessionID))
}
export function clearSessionPrefetchInflight(scope: ServerScope) {
const prefix = ScopedKey.prefix(scope)
for (const id of inflight.keys()) {
if (id.startsWith(prefix)) inflight.delete(id)
}
}
export function isSessionPrefetchCurrent(scope: ServerScope, directory: string, sessionID: string, value: number) {
return version(key(scope, directory, sessionID)) === value
}
export function runSessionPrefetch(input: {
directory: string
scope: ServerScope
sessionID: string
task: (value: number) => Promise<Meta | undefined>
}) {
const id = key(input.scope, input.directory, input.sessionID)
const pending = inflight.get(id)
if (pending) return pending
const value = version(id)
const promise = input.task(value).finally(() => {
if (inflight.get(id) === promise) inflight.delete(id)
})
inflight.set(id, promise)
return promise
}
export function setSessionPrefetch(input: {
directory: string
scope: ServerScope
sessionID: string
limit: number
cursor?: string
complete: boolean
at?: number
}) {
cache.set(key(input.scope, input.directory, input.sessionID), {
limit: input.limit,
cursor: input.cursor,
complete: input.complete,
at: input.at ?? Date.now(),
})
}
export function clearSessionPrefetch(scope: ServerScope, directory: string, sessionIDs: Iterable<string>) {
for (const sessionID of sessionIDs) {
if (!sessionID) continue
const id = key(scope, directory, sessionID)
rev.set(id, version(id) + 1)
cache.delete(id)
inflight.delete(id)
}
}
export function clearSessionPrefetchDirectory(scope: ServerScope, directory: string) {
const prefix = ScopedKey.prefix(scope, directory)
const keys = new Set([...cache.keys(), ...inflight.keys()])
for (const id of keys) {
if (!id.startsWith(prefix)) continue
rev.set(id, version(id) + 1)
cache.delete(id)
inflight.delete(id)
}
}
@@ -15,7 +15,7 @@ import type {
Todo, Todo,
VcsInfo, VcsInfo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store" import type { SetStoreFunction, Store } from "solid-js/store"
@@ -1,5 +1,5 @@
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
+1 -1
View File
@@ -85,7 +85,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
}, },
}, },
}, },
ensureServerCtx(conn: ServerConnection.Any) { createServerCtx(conn: ServerConnection.Any) {
return ensureServerCtx(conn) return ensureServerCtx(conn)
}, },
} }
+3 -13
View File
@@ -16,7 +16,6 @@ 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 }
@@ -80,7 +79,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"; sessionId: string; server?: ServerConnection.Key } | { type: "session"; dir: string; dirBase64: string; 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 ?? []
@@ -132,14 +131,6 @@ 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" }
@@ -147,7 +138,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", sessionId: id } if (id) return { type: "session", dir, dirBase64, sessionId: id }
return { type: "dir-new-sesssion", dir, dirBase64 } return { type: "dir-new-sesssion", dir, dirBase64 }
} }
@@ -163,7 +154,6 @@ 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 }
}) })
@@ -582,7 +572,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: serverSdk().scope, dir, id, at: Date.now() }) setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() })
}, },
clearTabs() { clearTabs() {
if (!store.handoff?.tabs) return if (!store.handoff?.tabs) return
+11 -10
View File
@@ -1,5 +1,5 @@
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { 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"
@@ -7,6 +7,7 @@ import { useServerSync } from "./server-sync"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { Binary } from "@opencode-ai/core/util/binary"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2" import { EventSessionError } from "@opencode-ai/sdk/v2"
@@ -107,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: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => { init: () => {
const params = useParams() const params = useParams()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
@@ -118,10 +119,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const empty: Notification[] = [] const empty: Notification[] = []
const currentDirectory = createMemo(() => { const currentDirectory = createMemo(() => {
return props.directory?.() ?? decode64(params.dir) return decode64(params.dir)
}) })
const currentSession = createMemo(() => props.sessionID?.() ?? params.id) const currentSession = createMemo(() => 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"]),
@@ -207,12 +208,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const lookup = async (directory: string, sessionID?: string) => { const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined if (!sessionID) return undefined
const sync = serverSync().ensureDirSyncContext(directory) const [syncStore] = serverSync().child(directory, { bootstrap: false })
const session = sync.session.get(sessionID) const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
if (session) return session if (match.found) return syncStore.session[match.index]
return sync.session return serverSDK()
.sync(sessionID) .client.session.get({ directory, sessionID })
.then(() => sync.session.get(sessionID)) .then((x) => x.data)
.catch(() => undefined) .catch(() => undefined)
} }
+4 -4
View File
@@ -1,4 +1,4 @@
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" import { 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: (props: { directory?: Accessor<string | undefined> }) => { init: () => {
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 = props.directory?.() ?? decode64(params.dir) const 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 = props.directory?.() ?? decode64(params.dir) const 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
+7 -47
View File
@@ -1,17 +1,12 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { 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, type Accessor } 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"
import type { FileSelection } from "@/context/file" 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"
import { useTabs, type Tab } from "./tabs"
import { useServer } from "./server"
import { requireServerKey } from "@/utils/session-route"
import { useSettings } from "./settings"
interface PartBase { interface PartBase {
content: string content: string
@@ -185,7 +180,7 @@ function promptTarget(serverScope: ServerScope, scope: Scope) {
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]) return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
} }
export function createPromptSession(serverScope: ServerScope, scope: Scope) { function createPromptSession(serverScope: ServerScope, scope: Scope) {
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
promptTarget(serverScope, scope), promptTarget(serverScope, scope),
createStore<PromptStore>(promptStore()), createStore<PromptStore>(promptStore()),
@@ -194,12 +189,6 @@ export function createPromptSession(serverScope: ServerScope, scope: Scope) {
return { ready, ...createPromptStateValue(store, setStore) } return { ready, ...createPromptStateValue(store, setStore) }
} }
export function createPromptReady(session: Accessor<PromptSession>) {
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
}
function promptStore(): PromptStore { function promptStore(): PromptStore {
return { return {
prompt: clonePrompt(DEFAULT_PROMPT), prompt: clonePrompt(DEFAULT_PROMPT),
@@ -257,28 +246,18 @@ export function createPromptState() {
const [store, setStore] = createStore<PromptStore>(promptStore()) const [store, setStore] = createStore<PromptStore>(promptStore())
const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return { return {
ready, ready: () => ready,
...createPromptStateValue(store, setStore), ...createPromptStateValue(store, setStore),
} }
} }
export const createTabPromptState = (
tabs: ReturnType<typeof useTabs>,
tab: Tab,
...args: Parameters<typeof createPromptSession>
) => tabs.state(tab, "prompt", () => createPromptSession(...args))
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
name: "Prompt", name: "Prompt",
gate: false, gate: false,
init: () => { init: () => {
const params = useParams<{ serverKey?: string; id?: string }>() const params = useParams()
const sdk = useSDK()
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const server = useServer()
const tabs = useTabs()
const settings = useSettings()
const cache = new Map<string, PromptCacheEntry>() const cache = new Map<string, PromptCacheEntry>()
const disposeAll = () => { const disposeAll = () => {
@@ -301,25 +280,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
const owner = getOwner() const owner = getOwner()
const tab = createMemo<Tab | undefined>(() => {
if (!settings.general.newLayoutDesigns()) return
if (search.draftId) {
return tabs.store.find((item) => item.type === "draft" && item.draftID === search.draftId)
}
if (!params.id) return
const serverKey = params.serverKey ? requireServerKey(params.serverKey) : server.key
return (
tabs.store.find(
(item) => item.type === "session" && item.server === serverKey && item.sessionId === params.id,
) ?? { type: "session", server: serverKey, sessionId: params.id }
)
})
const load = (scope: Scope) => { const load = (scope: Scope) => {
const current = tab()
if (current) {
return createTabPromptState(tabs, current, serverSDK().scope, scope)
}
const key = scopeKey(scope) const key = scopeKey(scope)
const existing = cache.get(key) const existing = cache.get(key)
if (existing) { if (existing) {
@@ -342,13 +303,12 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
const session = createMemo(() => const session = createMemo(() =>
load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }), load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }),
) )
const pick = (scope?: Scope) => (scope ? load(scope) : session()) const pick = (scope?: Scope) => (scope ? load(scope) : session())
const ready = createPromptReady(session)
return { return {
ready, ready: () => session().ready,
current: () => session().current(), current: () => session().current(),
cursor: () => session().cursor(), cursor: () => session().cursor(),
dirty: () => session().dirty(), dirty: () => session().dirty(),
+2 -2
View File
@@ -2,7 +2,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, createMemo } from "solid-js" import { type Accessor, createMemo } from "solid-js"
import { type ServerSDK, useServerSDK } from "./server-sdk" import { type ServerSDK, useServerSDK } from "./server-sdk"
export type DirectorySDK = ReturnType<ServerSDK["ensureDirSdkContext"]> export type DirectorySDK = ReturnType<ServerSDK["createDirSdkContext"]>
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK", name: "SDK",
@@ -11,7 +11,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
return createMemo(() => { return createMemo(() => {
const directory = typeof props.directory === "function" ? props.directory() : props.directory const directory = typeof props.directory === "function" ? props.directory() : props.directory
return serverSDK().ensureDirSdkContext(directory) return serverSDK().createDirSdkContext(directory)
}) })
}, },
}) })
+3 -3
View File
@@ -289,13 +289,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase> type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
export type ServerSDK = ServerSDKBase & { export type ServerSDK = ServerSDKBase & {
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext> createDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
} }
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK { export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
const sdk = createServerSdkContextBase(server, scope) const sdk = createServerSdkContextBase(server, scope)
return Object.assign(sdk, { return Object.assign(sdk, {
ensureDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)), createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
}) })
} }
@@ -311,7 +311,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
return createMemo<ServerSDK>(() => { return createMemo<ServerSDK>(() => {
const conn = props.server?.() ?? server.current const conn = props.server?.() ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable")) if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
return global.ensureServerCtx(conn).sdk return global.createServerCtx(conn).sdk
}) })
}, },
}) })
@@ -1,98 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
import { createServerSession } from "./server-session"
const session = (id: string, parentID?: string): Session => ({
id,
slug: id,
projectID: "project",
directory: "/repo",
title: id,
version: "1",
parentID,
time: { created: 1, updated: 1 },
})
function setup(sessions: Record<string, Session>) {
const get: unknown[] = []
const messages: unknown[] = []
const client = {
session: {
get: async (input: unknown) => {
get.push(input)
const id = (input as { sessionID: string }).sessionID
return { data: sessions[id] }
},
messages: async (input: unknown) => {
messages.push(input)
return { data: [], response: { headers: new Headers() } }
},
diff: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
},
} as unknown as OpencodeClient
return { get, messages, store: createServerSession(client) }
}
describe("server session", () => {
test("resolves lineage by session ID without directory", async () => {
const ctx = setup({ child: session("child", "root"), root: session("root") })
const result = await ctx.store.lineage.resolve("child")
expect(result.root.id).toBe("root")
expect(ctx.get).toEqual([{ sessionID: "child" }, { sessionID: "root" }])
expect(ctx.store.lineage.peek("child")).toEqual(result)
})
test("loads session content through the server client", async () => {
const ctx = setup({ root: session("root") })
await ctx.store.sync("root")
expect(ctx.get).toEqual([{ sessionID: "root" }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 2, before: undefined }])
expect(ctx.store.data.message.root).toEqual([])
})
test("applies events without a directory store", () => {
const ctx = setup({})
ctx.store.apply({ type: "session.created", properties: { info: session("root") } })
ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } })
expect(ctx.store.get("root")?.directory).toBe("/repo")
expect(ctx.store.data.session_working("root")).toBe(true)
})
test("preserves pinned session content under server-wide cache pressure", () => {
const ctx = setup({})
ctx.store.pin("active")
ctx.store.optimistic.add({
sessionID: "active",
message: {
id: "message",
sessionID: "active",
role: "assistant",
time: { created: 1 },
parentID: "parent",
modelID: "model",
providerID: "provider",
mode: "build",
agent: "agent",
path: { cwd: "/repo", root: "/repo" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [],
})
for (let index = 0; index < 50; index++) {
ctx.store.apply({
type: "session.status",
properties: { sessionID: `session-${index}`, status: { type: "busy" } },
})
}
expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"])
})
})
-626
View File
@@ -1,626 +0,0 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import type {
Message,
OpencodeClient,
Part,
PermissionRequest,
QuestionRequest,
Session,
SessionStatus,
SnapshotFileDiff,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
import { rootSession } from "@/utils/session-route"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 2
const historyMessagePageSize = 200
const sessionInfoLimit = 2_048
type OptimisticItem = {
message: Message
parts: Part[]
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
function mergeOptimisticPage(
page: { session: Message[]; part: { id: string; part: Part[] }[]; cursor?: string; complete: boolean },
items: OptimisticItem[],
) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, item.part]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
if (!result.found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (result.found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, merge(current ?? [], item.parts))
}
return {
...page,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
confirmed,
}
}
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key)
if (pending) return pending
const promise = task().finally(() => {
if (map.get(key) === promise) map.delete(key)
})
map.set(key, promise)
return promise
}
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const items = new Map(a.map((item) => [item.id, item] as const))
for (const item of b) items.set(item.id, item)
return [...items.values()].sort((x, y) => cmp(x.id, y.id))
}
export function createServerSession(client: OpencodeClient) {
const [data, setData] = createStore({
info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>,
session_diff: {} as Record<string, SnapshotFileDiff[]>,
todo: {} as Record<string, Todo[]>,
permission: {} as Record<string, PermissionRequest[]>,
question: {} as Record<string, QuestionRequest[]>,
message: {} as Record<string, Message[]>,
part: {} as Record<string, Part[]>,
part_text_accum_delta: {} as Record<string, string>,
session_working(id: string) {
return (this.session_status[id]?.type ?? "idle") !== "idle"
},
})
const requests = new Map<string, Promise<Session>>()
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const seen = new Set<string>()
const infoSeen = new Set<string>()
const pinned = new Map<string, number>()
const generations = new Map<string, number>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number | undefined>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean | undefined>,
loading: {} as Record<string, boolean | undefined>,
at: {} as Record<string, number | undefined>,
})
const remember = (session: Session) => {
setData("info", session.id, reconcile(session))
infoSeen.delete(session.id)
infoSeen.add(session.id)
if (infoSeen.size > sessionInfoLimit) {
const preserve = new Set([
...pinned.keys(),
...requests.keys(),
...Object.entries(data.permission)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.question)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.session_status)
.filter(([, status]) => status.type !== "idle")
.map(([sessionID]) => sessionID),
])
for (const sessionID of preserve) {
let current = data.info[sessionID]
while (current) {
preserve.add(current.id)
current = current.parentID ? data.info[current.parentID] : undefined
}
}
const stale: string[] = []
for (const sessionID of infoSeen) {
if (infoSeen.size - stale.length <= sessionInfoLimit) break
if (!preserve.has(sessionID)) stale.push(sessionID)
}
stale.forEach((sessionID) => infoSeen.delete(sessionID))
setData(
"info",
produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])),
)
}
return session
}
const resolve = (sessionID: string, options?: { force?: boolean }) => {
const cached = data.info[sessionID]
if (cached && !options?.force) return Promise.resolve(cached)
const pending = requests.get(sessionID)
if (pending) return pending
const generation = generations.get(sessionID) ?? 0
const request = client.session.get({ sessionID }).then((result) => {
if (!result.data) throw new Error(`Session not found: ${sessionID}`)
if ((generations.get(sessionID) ?? 0) !== generation) return result.data
return remember(result.data)
})
requests.set(sessionID, request)
void request.then(
() => {
if (requests.get(sessionID) === request) requests.delete(sessionID)
},
() => {
if (requests.get(sessionID) === request) requests.delete(sessionID)
},
)
return request
}
const peekLineage = (sessionID: string) => {
const session = data.info[sessionID]
if (!session) return
const seen = new Set([session.id])
let root = session
while (root.parentID) {
if (seen.has(root.parentID)) throw new Error(`Session parent cycle: ${root.parentID}`)
seen.add(root.parentID)
const parent = data.info[root.parentID]
if (!parent) return
root = parent
}
return { session, root }
}
const clearOptimistic = (sessionID: string, messageID?: string) => {
if (!messageID) {
optimistic.delete(sessionID)
return
}
const items = optimistic.get(sessionID)
if (!items) return
items.delete(messageID)
if (items.size === 0) optimistic.delete(sessionID)
}
const evict = (sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
sessionIDs.forEach((sessionID) => {
generations.set(sessionID, (generations.get(sessionID) ?? 0) + 1)
clearOptimistic(sessionID)
requests.delete(sessionID)
inflight.delete(sessionID)
inflightDiff.delete(sessionID)
inflightTodo.delete(sessionID)
})
setData(
produce((draft) => {
dropSessionCaches(draft, sessionIDs)
}),
)
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
delete draft.limit[sessionID]
delete draft.cursor[sessionID]
delete draft.complete[sessionID]
delete draft.loading[sessionID]
delete draft.at[sessionID]
}
}),
)
}
const protectedSessions = () =>
new Set([
...pinned.keys(),
...requests.keys(),
...inflight.keys(),
...inflightDiff.keys(),
...inflightTodo.keys(),
...optimistic.keys(),
...Object.entries(data.permission)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.question)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.session_status)
.filter(([, status]) => status.type !== "idle")
.map(([sessionID]) => sessionID),
])
const touch = (sessionID: string) =>
evict(
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
)
const fetchMessages = async (sessionID: string, limit: number, before?: string) => {
const response = await retry(() => client.session.messages({ sessionID, limit, before }))
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
return {
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
part: items.map((item) => ({
id: item.info.id,
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
})),
cursor: response.response.headers.get("x-next-cursor") ?? undefined,
complete: !response.response.headers.get("x-next-cursor"),
}
}
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
if (meta.loading[sessionID]) return
const generation = generations.get(sessionID) ?? 0
setMeta("loading", sessionID, true)
await fetchMessages(sessionID, limit, before)
.then((page) => {
if ((generations.get(sessionID) ?? 0) !== generation) return
const next = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
next.confirmed.forEach((messageID) => clearOptimistic(sessionID, messageID))
const messages = mode === "prepend" ? merge(data.message[sessionID] ?? [], next.session) : next.session
batch(() => {
setData("message", sessionID, reconcile(messages, { key: "id" }))
for (const item of next.part) {
const parts = item.part.filter((part) => !SKIP_PARTS.has(part.type))
if (parts.length) setData("part", item.id, reconcile(parts, { key: "id" }))
}
setMeta("limit", sessionID, messages.length)
setMeta("cursor", sessionID, next.cursor)
setMeta("complete", sessionID, next.complete)
setMeta("at", sessionID, Date.now())
})
})
.finally(() => {
if ((generations.get(sessionID) ?? 0) === generation) setMeta("loading", sessionID, false)
})
}
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
touch(sessionID)
return runInflight(inflight, sessionID, async () => {
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
if (cached && data.info[sessionID] && !options?.force) return
await Promise.all([
resolve(sessionID, options),
cached && !options?.force
? Promise.resolve()
: loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize),
])
})
}
const prefetch = async (sessionID: string, limit: number) => {
touch(sessionID)
await inflight.get(sessionID)
if (
Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 &&
(meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= limit)
)
return
await runInflight(inflight, sessionID, () => loadMessages(sessionID, limit))
}
const eventSessionID = (event: { type: string; properties?: unknown }) => {
const properties = event.properties
if (!properties || typeof properties !== "object") return
if ("sessionID" in properties && typeof properties.sessionID === "string") return properties.sessionID
if (
"info" in properties &&
properties.info &&
typeof properties.info === "object" &&
"sessionID" in properties.info &&
typeof properties.info.sessionID === "string"
)
return properties.info.sessionID
if (
"part" in properties &&
properties.part &&
typeof properties.part === "object" &&
"sessionID" in properties.part &&
typeof properties.part.sessionID === "string"
)
return properties.part.sessionID
}
const apply = (event: { type: string; properties?: unknown }) => {
const eventID = eventSessionID(event)
if (eventID) {
touch(eventID)
if (!data.info[eventID]) void resolve(eventID).catch(() => {})
}
switch (event.type) {
case "session.created":
remember((event.properties as { info: Session }).info)
return
case "session.updated": {
const info = (event.properties as { info: Session }).info
remember(info)
if (info.time.archived) evict([info.id])
return
}
case "session.deleted": {
const sessionID = (event.properties as { info: Session }).info.id
infoSeen.delete(sessionID)
setData(
"info",
produce((draft) => void delete draft[sessionID]),
)
evict([sessionID])
return
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
return
}
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
return
}
case "session.status": {
const props = event.properties as { sessionID: string; status: SessionStatus }
setData("session_status", props.sessionID, reconcile(props.status))
return
}
case "message.updated": {
const info = cleanMessage((event.properties as { info: Message }).info)
const messages = data.message[info.sessionID]
if (!messages) {
setData("message", info.sessionID, [info])
return
}
const result = Binary.search(messages, info.id, (message) => message.id)
if (result.found) setData("message", info.sessionID, result.index, reconcile(info))
if (!result.found)
setData("message", info.sessionID, (value = []) => {
const next = value.slice()
next.splice(result.index, 0, info)
return next
})
return
}
case "message.removed": {
const props = event.properties as { sessionID: string; messageID: string }
setData(
produce((draft) => {
const messages = draft.message[props.sessionID]
if (messages) {
const result = Binary.search(messages, props.messageID, (message) => message.id)
if (result.found) messages.splice(result.index, 1)
}
for (const part of draft.part[props.messageID] ?? []) delete draft.part_text_accum_delta[part.id]
delete draft.part[props.messageID]
}),
)
return
}
case "message.part.updated": {
const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) return
setData(
"part_text_accum_delta",
produce((draft) => void delete draft[part.id]),
)
const parts = data.part[part.messageID]
if (!parts) {
setData("part", part.messageID, [part])
return
}
const result = Binary.search(parts, part.id, (item) => item.id)
if (result.found) setData("part", part.messageID, result.index, reconcile(part))
if (!result.found)
setData("part", part.messageID, (value = []) => {
const next = value.slice()
next.splice(result.index, 0, part)
return next
})
return
}
case "message.part.removed": {
const props = event.properties as { messageID: string; partID: string }
setData(
produce((draft) => {
delete draft.part_text_accum_delta[props.partID]
const parts = draft.part[props.messageID]
if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id)
if (result.found) parts.splice(result.index, 1)
if (parts.length === 0) delete draft.part[props.messageID]
}),
)
return
}
case "message.part.delta": {
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
const parts = data.part[props.messageID]
if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id)
if (!result.found) return
const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field]
setData(
"part_text_accum_delta",
props.partID,
(value) => (value ?? (typeof current === "string" ? current : "")) + props.delta,
)
setData(
"part",
props.messageID,
produce((draft) => {
if (!draft) return
const part = draft[result.index]
const field = props.field as keyof typeof part
;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta
}),
)
return
}
case "permission.asked": {
const permission = event.properties as PermissionRequest
const permissions = data.permission[permission.sessionID] ?? []
const result = Binary.search(permissions, permission.id, (item) => item.id)
if (result.found) setData("permission", permission.sessionID, result.index, reconcile(permission))
if (!result.found)
setData(
"permission",
permission.sessionID,
produce((draft = []) => void draft.splice(result.index, 0, permission)),
)
return
}
case "permission.replied": {
const props = event.properties as { sessionID: string; requestID: string }
setData(
"permission",
props.sessionID,
produce((draft) => {
if (!draft) return
const result = Binary.search(draft, props.requestID, (item) => item.id)
if (result.found) draft.splice(result.index, 1)
}),
)
return
}
case "question.asked": {
const question = event.properties as QuestionRequest
const questions = data.question[question.sessionID] ?? []
const result = Binary.search(questions, question.id, (item) => item.id)
if (result.found) setData("question", question.sessionID, result.index, reconcile(question))
if (!result.found)
setData(
"question",
question.sessionID,
produce((draft = []) => void draft.splice(result.index, 0, question)),
)
return
}
case "question.replied":
case "question.rejected": {
const props = event.properties as { sessionID: string; requestID: string }
setData(
"question",
props.sessionID,
produce((draft) => {
if (!draft) return
const result = Binary.search(draft, props.requestID, (item) => item.id)
if (result.found) draft.splice(result.index, 1)
}),
)
}
}
}
return {
data,
set: setData,
get: (sessionID: string) => data.info[sessionID],
peek: (sessionID: string) => data.info[sessionID],
remember,
resolve,
lineage: {
peek: peekLineage,
async resolve(sessionID: string) {
const session = await resolve(sessionID)
return { session, root: await rootSession(session, resolve) }
},
},
sync,
prefetch,
shouldPrefetch(sessionID: string, limit: number) {
if (data.message[sessionID] === undefined) return true
if (Date.now() - (meta.at[sessionID] ?? 0) > 15_000) return true
if (meta.complete[sessionID]) return false
return (meta.limit[sessionID] ?? 0) <= limit
},
fresh(sessionID: string, ttl: number) {
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
},
optimistic: {
add(input: { sessionID: string; message: Message; parts: Part[] }) {
const items = optimistic.get(input.sessionID)
if (items) items.set(input.message.id, input)
if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, input]]))
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
setData(
"part",
input.message.id,
input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
)
},
remove(input: { sessionID: string; messageID: string }) {
clearOptimistic(input.sessionID, input.messageID)
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
setData(
"part",
produce((draft) => void delete draft[input.messageID]),
)
},
},
diff(sessionID: string, options?: { force?: boolean }) {
touch(sessionID)
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
return runInflight(inflightDiff, sessionID, () => {
const generation = generations.get(sessionID) ?? 0
return retry(() => client.session.diff({ sessionID })).then((result) => {
if ((generations.get(sessionID) ?? 0) !== generation) return
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
})
})
},
todo(sessionID: string, options?: { force?: boolean }) {
touch(sessionID)
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
return runInflight(inflightTodo, sessionID, () => {
const generation = generations.get(sessionID) ?? 0
return retry(() => client.session.todo({ sessionID })).then((result) => {
if ((generations.get(sessionID) ?? 0) !== generation) return
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
})
})
},
history: {
more: (sessionID: string) =>
data.message[sessionID] !== undefined &&
meta.limit[sessionID] !== undefined &&
!meta.complete[sessionID] &&
!!meta.cursor[sessionID],
loading: (sessionID: string) => meta.loading[sessionID] ?? false,
async loadMore(sessionID: string, count = historyMessagePageSize) {
touch(sessionID)
if (meta.loading[sessionID] || meta.complete[sessionID] || !meta.cursor[sessionID]) return
await loadMessages(sessionID, count, meta.cursor[sessionID], "prepend")
},
},
evict(sessionID: string) {
if (protectedSessions().has(sessionID)) return
seen.delete(sessionID)
evict([sessionID])
},
pin(sessionID: string) {
pinned.set(sessionID, (pinned.get(sessionID) ?? 0) + 1)
touch(sessionID)
},
unpin(sessionID: string) {
const count = pinned.get(sessionID)
if (!count || count === 1) pinned.delete(sessionID)
if (count && count > 1) pinned.set(sessionID, count - 1)
},
apply,
}
}
export type ServerSession = ReturnType<typeof createServerSession>
+35 -20
View File
@@ -1,4 +1,4 @@
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse } from "@opencode-ai/sdk/v2/client" import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
@@ -17,7 +17,8 @@ import {
loadProvidersQuery, loadProvidersQuery,
} from "./global-sync/bootstrap" } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store" import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim" import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types" import type { ProjectMeta } from "./global-sync/types"
@@ -28,8 +29,7 @@ import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils" import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/utils/path-key" import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync" import { createDirSyncContext } from "./directory-sync"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { createRefCountMap } from "@/utils/refcount" import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global" import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server" import { ServerConnection, useServer } from "./server"
@@ -37,13 +37,15 @@ import { retry } from "@opencode-ai/core/util/retry"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
import { toggleMcp } from "./global-sync/mcp" import { toggleMcp } from "./global-sync/mcp"
import { createServerSession } from "./server-session"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
error?: InitError error?: InitError
path: Path path: Path
project: Project[] project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: NormalizedProviderListResponse provider: NormalizedProviderListResponse
provider_auth: ProviderAuthResponse provider_auth: ProviderAuthResponse
config: Config config: Config
@@ -115,6 +117,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return !bootstrap.isPending return !bootstrap.isPending
}, },
project: [], project: [],
session_todo: {},
provider_auth: {}, provider_auth: {},
get path() { get path() {
const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" } const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" }
@@ -184,6 +187,20 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return (setGlobalStore as (...args: unknown[]) => unknown)(...input) return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore }) as typeof setGlobalStore
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!sessionID) return
if (!todos) {
setGlobalStore(
"session_todo",
produce((draft) => {
delete draft[sessionID]
}),
)
return
}
setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
}
const paused = () => untrack(() => globalStore.reload) !== undefined const paused = () => untrack(() => globalStore.reload) !== undefined
const queue = createRefreshQueue({ const queue = createRefreshQueue({
@@ -193,8 +210,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
bootstrapInstance, bootstrapInstance,
}) })
const session = createServerSession(serverSDK.client)
const children = createChildStoreManager({ const children = createChildStoreManager({
owner, owner,
scope: serverSDK.scope, scope: serverSDK.scope,
@@ -223,6 +238,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
sessionMeta.delete(key) sessionMeta.delete(key)
sdkCache.delete(key) sdkCache.delete(key)
clearProviderRev(serverSDK.scope, key) clearProviderRev(serverSDK.scope, key)
clearSessionPrefetchDirectory(serverSDK.scope, key)
}, },
translate: language.t, translate: language.t,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
@@ -246,10 +262,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (meta && meta.limit >= retainedLimit) { if (meta && meta.limit >= retainedLimit) {
const next = trimSessions(store.session, { const next = trimSessions(store.session, {
limit: retainedLimit, limit: retainedLimit,
permission: session.data.permission, permission: store.permission,
}) })
if (next.length !== store.session.length) { if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" })) setStore("session", reconcile(next, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
} }
children.unpin(key) children.unpin(key)
return return
@@ -272,12 +289,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0) const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0)
const childSessions = store.session.filter((s) => !!s.parentID) const childSessions = store.session.filter((s) => !!s.parentID)
const next = trimSessions([...nonArchived, ...childSessions], { const sessions = trimSessions([...nonArchived, ...childSessions], {
limit, limit,
permission: session.data.permission, permission: store.permission,
}) })
batch(() => { batch(() => {
next.forEach(session.remember)
setStore( setStore(
"sessionTotal", "sessionTotal",
estimateRootSessionTotal({ estimateRootSessionTotal({
@@ -286,7 +302,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
limited: x.limited, limited: x.limited,
}), }),
) )
setStore("session", reconcile(next, { key: "id" })) setStore("session", reconcile(sessions, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
}) })
sessionMeta.set(key, { limit }) sessionMeta.set(key, { limit })
}) })
@@ -340,7 +357,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
loadSessions, loadSessions,
translate: language.t, translate: language.t,
queryClient, queryClient,
session,
}) })
}) })
@@ -358,8 +374,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const event = e.details const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500 const recent = bootingRoot || Date.now() - bootedAt < 1500
session.apply(event)
if (directory === "global") { if (directory === "global") {
applyGlobalEvent({ applyGlobalEvent({
event, event,
@@ -389,9 +403,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
store, store,
setStore, setStore,
push: queue.push, push: queue.push,
setSessionTodo,
retainedLimit: sessionMeta.get(key)?.limit, retainedLimit: sessionMeta.get(key)?.limit,
sessionContent: false,
permission: session.data.permission,
vcsCache: children.vcsCache.get(key), vcsCache: children.vcsCache.get(key),
loadLsp: () => { loadLsp: () => {
void queryClient.fetchQuery(queryOptionsApi.lsp(key)) void queryClient.fetchQuery(queryOptionsApi.lsp(key))
@@ -465,7 +478,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
// bootstrap, // bootstrap,
updateConfig: updateConfigMutation.mutateAsync, updateConfig: updateConfigMutation.mutateAsync,
project: projectApi, project: projectApi,
session, todo: {
set: setSessionTodo,
},
mcp: { mcp: {
toggle: async (directory: string, name: string) => { toggle: async (directory: string, name: string) => {
const key = directoryKey(directory) const key = directoryKey(directory)
@@ -494,7 +509,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
export function createServerSyncContext(serverSDK: ServerSDK) { export function createServerSyncContext(serverSDK: ServerSDK) {
const inner = createServerSyncContextInner(serverSDK) const inner = createServerSyncContextInner(serverSDK)
return Object.assign(inner, { return Object.assign(inner, {
ensureDirSyncContext: createRefCountMap( createDirSyncContext: createRefCountMap(
(dir) => createDirSyncContext(dir, inner, serverSDK), (dir) => createDirSyncContext(dir, inner, serverSDK),
(dir) => inner.disableMcp(dir), (dir) => inner.disableMcp(dir),
directoryKey, directoryKey,
@@ -516,7 +531,7 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
return createMemo<ServerSync>(() => { return createMemo<ServerSync>(() => {
const conn = props.server?.() ?? server.current const conn = props.server?.() ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable")) if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
return global.ensureServerCtx(conn).sync return global.createServerCtx(conn).sync
}) })
}, },
}) })
+9 -9
View File
@@ -31,8 +31,8 @@ export interface Settings {
showReasoningSummaries: boolean showReasoningSummaries: boolean
shellToolPartsExpanded: boolean shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean editToolPartsExpanded: boolean
showSessionProgressBar: boolean
showCustomAgents: boolean showCustomAgents: boolean
mobileTitlebarPosition: "top" | "bottom"
newLayoutDesigns?: boolean newLayoutDesigns?: boolean
} }
appearance: { appearance: {
@@ -116,8 +116,8 @@ const defaultSettings: Settings = {
showReasoningSummaries: false, showReasoningSummaries: false,
shellToolPartsExpanded: false, shellToolPartsExpanded: false,
editToolPartsExpanded: false, editToolPartsExpanded: false,
showSessionProgressBar: true,
showCustomAgents: false, showCustomAgents: false,
mobileTitlebarPosition: "top",
}, },
appearance: { appearance: {
fontSize: 14, fontSize: 14,
@@ -237,17 +237,17 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setEditToolPartsExpanded(value: boolean) { setEditToolPartsExpanded(value: boolean) {
setStore("general", "editToolPartsExpanded", value) setStore("general", "editToolPartsExpanded", value)
}, },
showSessionProgressBar: withFallback(
() => store.general?.showSessionProgressBar,
defaultSettings.general.showSessionProgressBar,
),
setShowSessionProgressBar(value: boolean) {
setStore("general", "showSessionProgressBar", value)
},
showCustomAgents, showCustomAgents,
setShowCustomAgents(value: boolean) { setShowCustomAgents(value: boolean) {
setStore("general", "showCustomAgents", value) setStore("general", "showCustomAgents", value)
}, },
mobileTitlebarPosition: withFallback(
() => store.general?.mobileTitlebarPosition,
defaultSettings.general.mobileTitlebarPosition,
),
setMobileTitlebarPosition(value: "top" | "bottom") {
setStore("general", "mobileTitlebarPosition", value)
},
newLayoutDesigns, newLayoutDesigns,
setNewLayoutDesigns(value: boolean) { setNewLayoutDesigns(value: boolean) {
setStore("general", "newLayoutDesigns", value) setStore("general", "newLayoutDesigns", value)
+1 -1
View File
@@ -113,7 +113,7 @@ export const useSync = () => {
const serverSync = useServerSync() const serverSync = useServerSync()
const sdk = useSDK() const sdk = useSDK()
return createMemo(() => serverSync().ensureDirSyncContext(sdk().directory)) return createMemo(() => serverSync().createDirSyncContext(sdk().directory))
} }
export type DirectorySync = ReturnType<ReturnType<typeof useSync>> export type DirectorySync = ReturnType<ReturnType<typeof useSync>>
-33
View File
@@ -1,33 +0,0 @@
import { createRoot, type Owner } from "solid-js"
type Entry = {
value: unknown
dispose: VoidFunction
}
export function createTabMemory(owner: Owner | null) {
const entries = new Map<string, Map<string, Entry>>()
const remove = (key: string) => {
const state = entries.get(key)
if (!state) return
for (const entry of state.values()) entry.dispose()
entries.delete(key)
}
return {
ensure<T>(key: string, name: string, init: () => T) {
const state = entries.get(key) ?? new Map<string, Entry>()
if (!entries.has(key)) entries.set(key, state)
const existing = state.get(name)
if (existing) return existing.value as T
const entry = createRoot((dispose) => ({ value: init(), dispose }), owner)
state.set(name, entry)
return entry.value
},
remove,
dispose() {
for (const key of entries.keys()) remove(key)
},
}
}
-24
View File
@@ -1,24 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./tab-memory"
describe("tab memory", () => {
test("keeps state until its tab is removed", () => {
createRoot((dispose) => {
const memory = createTabMemory(getOwner())
let disposed = 0
const first = memory.ensure("tab", "prompt", () => {
onCleanup(() => disposed++)
return { value: "prompt" }
})
expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
memory.remove("tab")
expect(disposed).toBe(1)
expect(memory.ensure("tab", "prompt", () => ({ value: "new" }))).not.toBe(first)
dispose()
})
})
})
+26 -25
View File
@@ -1,19 +1,19 @@
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"
import { createEffect, getOwner, onCleanup, startTransition } from "solid-js" import { createEffect, startTransition } from "solid-js"
import { useLocation, useNavigate, useParams } from "@solidjs/router" 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"
import { createTabMemory } from "./tab-memory"
export type SessionTab = { export type SessionTab = {
type: "session" type: "session"
server: ServerConnection.Key server: ServerConnection.Key
dirBase64: string
sessionId: string sessionId: string
} }
@@ -34,12 +34,16 @@ 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) : sessionHref(tab.server, tab.sessionId) tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${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) {
return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id) const dirBase64 = base64Encode(session.directory)
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({
@@ -67,7 +71,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const params = useParams() const params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const memory = createTabMemory(getOwner())
const closing = new Set<string>() const closing = new Set<string>()
let recentWrite = 0 let recentWrite = 0
@@ -91,39 +94,37 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform) for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
} }
onCleanup(memory.dispose)
createEffect(() => { createEffect(() => {
if (!ready() || !recentReady()) return if (!ready() || !recentReady()) return
const servers = new Set(server.list.map(ServerConnection.key)) const servers = new Set(server.list.map(ServerConnection.key))
const next = store.filter((tab) => servers.has(tab.server)) const next = store.filter((tab) => servers.has(tab.server))
if (next.length !== store.length) { if (next.length !== store.length) setStore(() => next)
for (const tab of store) {
if (!servers.has(tab.server)) memory.remove(tabKey(tab))
}
setStore(() => next)
}
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined) if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
}) })
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 = {
addSessionTab: (tab: Omit<SessionTab, "type">) => { addSessionTab: (tab: Omit<SessionTab, "type">) => {
const next = { type: "session" as const, ...tab } const next = { type: "session" as const, ...tab }
const existing = store.find((item) => tabKey(item) === tabKey(next)) if (closing.has(tabKey(next))) return
if (existing) return existing
setStore( setStore(
produce((tabs) => { produce((tabs) => {
if (tabs.some((item) => tabKey(item) === tabKey(next))) return if (tabs.some((item) => tabKey(item) === tabKey(next))) return
tabs.push(next) tabs.push(next)
}), }),
) )
return next
}, },
draft(draftID: string) { draft(draftID: string) {
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID) const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
@@ -162,7 +163,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next)) if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next))
if (active) navigateTab(next) if (active) navigateTab(next)
}) })
memory.remove(`draft:${draftID}`)
removeDraftPersisted(draftID) removeDraftPersisted(draftID)
}, },
removeTab: (index: number) => { removeTab: (index: number) => {
@@ -182,14 +182,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (nextTab) navigateTab(nextTab) if (nextTab) navigateTab(nextTab)
else navigate("/") else navigate("/")
}).finally(() => closing.delete(key)) }).finally(() => closing.delete(key))
memory.remove(key)
if (draftID) removeDraftPersisted(draftID) if (draftID) removeDraftPersisted(draftID)
}, },
removeServer(key: ServerConnection.Key) { removeServer(key: ServerConnection.Key) {
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : [])) const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
const removed = store.filter((tab) => tab.server === key).map(tabKey) const removed = store.filter((tab) => tab.server === key).map(tabKey)
setStore((tabs) => tabs.filter((tab) => tab.server !== key)) setStore((tabs) => tabs.filter((tab) => tab.server !== key))
for (const key of removed) memory.remove(key)
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined) if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
for (const draftID of drafts) removeDraftPersisted(draftID) for (const draftID of drafts) removeDraftPersisted(draftID)
if (server.key === key) navigate("/") if (server.key === key) navigate("/")
@@ -197,7 +195,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
removeSessions: (input: SessionTabsRemovedDetail) => { removeSessions: (input: SessionTabsRemovedDetail) => {
const removed = store const removed = store
.filter( .filter(
(tab) => tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId), (tab) =>
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(() => {
@@ -209,6 +211,7 @@ 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
@@ -221,12 +224,14 @@ 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)
} }
@@ -241,7 +246,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
) )
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined) if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
}) })
for (const key of removed) memory.remove(key)
}, },
select: navigateTab, select: navigateTab,
remember(tab: Tab) { remember(tab: Tab) {
@@ -261,9 +265,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
} }
navigate("/") navigate("/")
}, },
state<T>(tab: Tab, name: string, init: () => T) {
return memory.ensure(tabKey(tab), name, init)
},
} }
return { ...actions, store, ready, recentReady } return { ...actions, store, ready, recentReady }
+5 -7
View File
@@ -4,8 +4,7 @@ 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 { useServerSDK } from "./server-sdk" import { useServer } from "./server"
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"
@@ -375,11 +374,10 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
gate: false, gate: false,
init: () => { init: () => {
const sdk = useSDK() const sdk = useSDK()
const serverSDK = useServerSDK() const server = useServer()
const params = useParams() const params = useParams()
const cache = new Map<string, TerminalCacheEntry>() const cache = new Map<string, TerminalCacheEntry>()
const scope = () => serverSDK().scope const scope = server.scope()
const directory = createMemo(() => base64Encode(sdk().directory))
caches.add(cache) caches.add(cache)
onCleanup(() => caches.delete(cache)) onCleanup(() => caches.delete(cache))
@@ -423,11 +421,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value return entry.value
} }
const workspace = createMemo(() => loadWorkspace(directory(), params.id, scope())) const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope))
createEffect( createEffect(
on( on(
() => ({ dir: directory(), id: params.id, scope: scope() }), () => ({ dir: params.dir, id: params.id, 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
+2
View File
@@ -590,6 +590,8 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit", "settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني", "إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
"settings.general.row.showSessionProgressBar.title": "إظهار شريط تقدم الجلسة",
"settings.general.row.showSessionProgressBar.description": "عرض شريط التقدم المتحرك أعلى الجلسة أثناء عمل الوكيل",
"settings.general.row.wayland.title": "استخدام Wayland الأصلي", "settings.general.row.wayland.title": "استخدام Wayland الأصلي",
"settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.", "settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
+3
View File
@@ -598,6 +598,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição", "settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo", "Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
"settings.general.row.showSessionProgressBar.title": "Mostrar barra de progresso da sessão",
"settings.general.row.showSessionProgressBar.description":
"Exibir a barra de progresso animada no topo da sessão quando o agente estiver trabalhando",
"settings.general.row.wayland.title": "Usar Wayland nativo", "settings.general.row.wayland.title": "Usar Wayland nativo",
"settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.", "settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
+3
View File
@@ -663,6 +663,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje", "settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci", "Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
"settings.general.row.showSessionProgressBar.title": "Prikaži traku napretka sesije",
"settings.general.row.showSessionProgressBar.description":
"Prikaži animiranu traku napretka na vrhu sesije kada agent radi",
"settings.general.row.wayland.title": "Koristi nativni Wayland", "settings.general.row.wayland.title": "Koristi nativni Wayland",
"settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.", "settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
+3
View File
@@ -657,6 +657,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele", "settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen", "Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
"settings.general.row.showSessionProgressBar.title": "Vis sessionens fremdriftslinje",
"settings.general.row.showSessionProgressBar.description":
"Vis den animerede fremdriftslinje øverst i sessionen, når agenten arbejder",
"settings.general.row.wayland.title": "Brug native Wayland", "settings.general.row.wayland.title": "Brug native Wayland",
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.", "settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
+3
View File
@@ -609,6 +609,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen", "settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen", "Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
"settings.general.row.showSessionProgressBar.title": "Sitzungsfortschrittsleiste anzeigen",
"settings.general.row.showSessionProgressBar.description":
"Die animierte Fortschrittsleiste oben in der Sitzung anzeigen, wenn der Agent arbeitet",
"settings.general.row.wayland.title": "Natives Wayland verwenden", "settings.general.row.wayland.title": "Natives Wayland verwenden",
"settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.", "settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
+3 -5
View File
@@ -589,8 +589,6 @@ export const dict = {
"home.title": "Home", "home.title": "Home",
"home.projects": "Projects", "home.projects": "Projects",
"home.project.add": "Add project", "home.project.add": "Add project",
"home.server.collapse": "Collapse server projects",
"home.server.expand": "Expand server projects",
"home.sessions.search.placeholder": "Search sessions", "home.sessions.search.placeholder": "Search sessions",
"home.sessions.search.sessions": "Sessions", "home.sessions.search.sessions": "Sessions",
"home.sessions.search.noResults": "No sessions found for {{query}}", "home.sessions.search.noResults": "No sessions found for {{query}}",
@@ -841,9 +839,6 @@ export const dict = {
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar", "settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status", "settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the title bar", "settings.general.row.showStatus.description": "Show the server status button in the title bar",
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
"settings.general.row.mobileTitlebarBottom.description":
"Place the title bar and session tabs at the bottom of the screen on mobile",
"settings.general.row.showCustomAgents.title": "Custom agents", "settings.general.row.showCustomAgents.title": "Custom agents",
"settings.general.row.showCustomAgents.description": "Show the agent picker in the composer", "settings.general.row.showCustomAgents.description": "Show the agent picker in the composer",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries", "settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
@@ -854,6 +849,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts", "settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Show edit, write, and patch tool parts expanded by default in the timeline", "Show edit, write, and patch tool parts expanded by default in the timeline",
"settings.general.row.showSessionProgressBar.title": "Show session progress bar",
"settings.general.row.showSessionProgressBar.description":
"Display the animated progress bar at the top of the session when the agent is working",
"settings.general.row.newLayoutDesigns.title": "New layout and designs", "settings.general.row.newLayoutDesigns.title": "New layout and designs",
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI", "settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
"settings.general.row.pinchZoom.title": "Pinch to zoom", "settings.general.row.pinchZoom.title": "Pinch to zoom",
+3
View File
@@ -667,6 +667,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición", "settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo", "Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
"settings.general.row.showSessionProgressBar.title": "Mostrar barra de progreso de la sesión",
"settings.general.row.showSessionProgressBar.description":
"Mostrar la barra de progreso animada en la parte superior de la sesión cuando el agente esté trabajando",
"settings.general.row.wayland.title": "Usar Wayland nativo", "settings.general.row.wayland.title": "Usar Wayland nativo",
"settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.", "settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
+3
View File
@@ -606,6 +606,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit", "settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie", "Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
"settings.general.row.showSessionProgressBar.title": "Afficher la barre de progression de la session",
"settings.general.row.showSessionProgressBar.description":
"Afficher la barre de progression animée en haut de la session lorsque l'agent travaille",
"settings.general.row.wayland.title": "Utiliser Wayland natif", "settings.general.row.wayland.title": "Utiliser Wayland natif",
"settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.", "settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
+3
View File
@@ -595,6 +595,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開", "settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します", "タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
"settings.general.row.showSessionProgressBar.title": "セッション進行状況バーを表示",
"settings.general.row.showSessionProgressBar.description":
"エージェントの作業中に、セッション上部にアニメーション付きの進行状況バーを表示します",
"settings.general.row.wayland.title": "ネイティブWaylandを使用", "settings.general.row.wayland.title": "ネイティブWaylandを使用",
"settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。", "settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
+3
View File
@@ -591,6 +591,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "edit 도구 파트 펼치기", "settings.general.row.editToolPartsExpanded.title": "edit 도구 파트 펼치기",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"타임라인에서 기본적으로 edit, write, patch 도구 파트를 펼친 상태로 표시합니다", "타임라인에서 기본적으로 edit, write, patch 도구 파트를 펼친 상태로 표시합니다",
"settings.general.row.showSessionProgressBar.title": "세션 진행 표시줄 표시",
"settings.general.row.showSessionProgressBar.description":
"에이전트가 작업 중일 때 세션 상단에 애니메이션 진행 표시줄을 표시합니다",
"settings.general.row.wayland.title": "네이티브 Wayland 사용", "settings.general.row.wayland.title": "네이티브 Wayland 사용",
"settings.general.row.wayland.description": "Wayland에서 X11 폴백을 비활성화합니다. 다시 시작해야 합니다.", "settings.general.row.wayland.description": "Wayland에서 X11 폴백을 비활성화합니다. 다시 시작해야 합니다.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
+3
View File
@@ -664,6 +664,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler", "settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Vis edit-, write- og patch-verktøydeler utvidet som standard i tidslinjen", "Vis edit-, write- og patch-verktøydeler utvidet som standard i tidslinjen",
"settings.general.row.showSessionProgressBar.title": "Vis fremdriftslinje for sesjonen",
"settings.general.row.showSessionProgressBar.description":
"Vis den animerte fremdriftslinjen øverst i sesjonen når agenten jobber",
"settings.general.row.wayland.title": "Bruk innebygd Wayland", "settings.general.row.wayland.title": "Bruk innebygd Wayland",
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Krever omstart.", "settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Krever omstart.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
+3
View File
@@ -596,6 +596,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit", "settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu", "Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu",
"settings.general.row.showSessionProgressBar.title": "Pokazuj pasek postępu sesji",
"settings.general.row.showSessionProgressBar.description":
"Wyświetlaj animowany pasek postępu u góry sesji, gdy agent pracuje",
"settings.general.row.wayland.title": "Użyj natywnego Wayland", "settings.general.row.wayland.title": "Użyj natywnego Wayland",
"settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.", "settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
+3
View File
@@ -664,6 +664,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit", "settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию", "Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию",
"settings.general.row.showSessionProgressBar.title": "Показывать индикатор прогресса сессии",
"settings.general.row.showSessionProgressBar.description":
"Показывать анимированный индикатор прогресса вверху сессии, когда агент работает",
"settings.general.row.wayland.title": "Использовать нативный Wayland", "settings.general.row.wayland.title": "Использовать нативный Wayland",
"settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.", "settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
+3
View File
@@ -655,6 +655,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit", "settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์", "แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
"settings.general.row.showSessionProgressBar.title": "แสดงแถบความคืบหน้าของเซสชัน",
"settings.general.row.showSessionProgressBar.description":
"แสดงแถบความคืบหน้าแบบเคลื่อนไหวที่ด้านบนของเซสชันเมื่อเอเจนต์กำลังทำงาน",
"settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ", "settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ",
"settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท", "settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท",
"settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า", "settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า",
+4
View File
@@ -671,6 +671,10 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster", "Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
"settings.general.row.showSessionProgressBar.title": "Oturum ilerleme çubuğunu göster",
"settings.general.row.showSessionProgressBar.description":
"Ajan çalışırken oturumun üst kısmında animasyonlu ilerleme çubuğunu göster",
"settings.general.row.wayland.title": "Yerel Wayland kullan", "settings.general.row.wayland.title": "Yerel Wayland kullan",
"settings.general.row.wayland.description": "settings.general.row.wayland.description":
"Wayland'da X11 geri dönüşünü devre dışı bırak. Yeniden başlatma gerektirir.", "Wayland'da X11 geri dönüşünü devre dışı bırak. Yeniden başlatma gerektirir.",
+3
View File
@@ -781,6 +781,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування", "settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування",
"settings.general.row.editToolPartsExpanded.description": "settings.general.row.editToolPartsExpanded.description":
"Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі", "Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі",
"settings.general.row.showSessionProgressBar.title": "Показувати індикатор прогресу сесії",
"settings.general.row.showSessionProgressBar.description":
"Відображати анімований індикатор прогресу вгорі сесії, коли агент працює",
"settings.general.row.wayland.title": "Використовувати нативний Wayland", "settings.general.row.wayland.title": "Використовувати нативний Wayland",
"settings.general.row.wayland.description": "Вимкнути резервний X11 на Wayland. Потребує перезапуску.", "settings.general.row.wayland.description": "Вимкнути резервний X11 на Wayland. Потребує перезапуску.",

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