Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton f8e5b12f41 fix(core): preserve provider session failures 2026-06-24 22:43:18 -04:00
817 changed files with 15182 additions and 39537 deletions
-14
View File
@@ -34,25 +34,11 @@ jobs:
const now = Date.now(); const now = Date.now();
const twoHours = 2 * 60 * 60 * 1000; const twoHours = 2 * 60 * 60 * 1000;
const orgMemberAssociations = new Set(['OWNER', 'MEMBER']);
for (const item of items) { for (const item of items) {
const isPR = !!item.pull_request; const isPR = !!item.pull_request;
const kind = isPR ? 'PR' : 'issue'; const kind = isPR ? 'PR' : 'issue';
if (orgMemberAssociations.has(item.author_association)) {
core.info(`Skipping ${kind} #${item.number}; author association is ${item.author_association}`);
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
name: 'needs:compliance',
});
} catch (e) {}
continue;
}
const { data: comments } = await github.rest.issues.listComments({ const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
+1 -7
View File
@@ -38,7 +38,6 @@ jobs:
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created: opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
Issue number: ${{ github.event.issue.number }} Issue number: ${{ github.event.issue.number }}
Issue author association: ${{ github.event.issue.author_association }}
Lookup this issue with gh issue view ${{ github.event.issue.number }}. Lookup this issue with gh issue view ${{ github.event.issue.number }}.
@@ -50,8 +49,6 @@ jobs:
Check whether the issue follows our contributing guidelines and issue templates. Check whether the issue follows our contributing guidelines and issue templates.
If the issue author association is OWNER or MEMBER, skip this compliance check. Do not add the needs:compliance label for organization-owned issues.
This project has three issue templates that every issue MUST use one of: This project has three issue templates that every issue MUST use one of:
1. Bug Report - requires a Description field with real content 1. Bug Report - requires a Description field with real content
@@ -86,7 +83,7 @@ jobs:
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows: Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
If the issue is NOT compliant and the author association is not OWNER or MEMBER, start the comment with: If the issue is NOT compliant, start the comment with:
<!-- issue-compliance --> <!-- issue-compliance -->
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
@@ -151,12 +148,9 @@ jobs:
} }
run: | run: |
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited. opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
Issue author association: ${{ github.event.issue.author_association }}
Lookup this issue with gh issue view ${{ github.event.issue.number }}. Lookup this issue with gh issue view ${{ github.event.issue.number }}.
If the issue author association is OWNER or MEMBER, remove the needs:compliance label if present, delete the previous compliance comment if present, and do not post a new comment.
Re-check whether the issue now follows our contributing guidelines and issue templates. Re-check whether the issue now follows our contributing guidelines and issue templates.
This project has three issue templates that every issue MUST use one of: This project has three issue templates that every issue MUST use one of:
-5
View File
@@ -69,11 +69,6 @@ jobs:
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Check generated client
if: runner.os == 'Linux'
working-directory: packages/client
run: bun run check:generated
- name: Run HttpApi exerciser gates - name: Run HttpApi exerciser gates
if: runner.os == 'Linux' if: runner.os == 'Linux'
working-directory: packages/opencode working-directory: packages/opencode
-2
View File
@@ -1,4 +1,2 @@
sst-env.d.ts sst-env.d.ts
packages/desktop/src/bindings.ts packages/desktop/src/bindings.ts
packages/client/src/generated/
packages/client/src/generated-effect/
+1 -3
View File
@@ -1,6 +1,4 @@
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`. - To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
- The default branch in this repo is `dev`. - The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
-91
View File
@@ -57,34 +57,9 @@ The bounded projection of a Core-executed tool result persisted in Session histo
**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.
**Native Continuation Metadata**:
Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier.
**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.
**OpenCode Client**:
The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers.
_Avoid_: Remote client
**SDK Contract IR**:
The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter.
**Embedded OpenCode**:
A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly.
_Avoid_: Local implementation
**Page**:
A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction.
_Avoid_: Response envelope
## Relationships ## Relationships
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**. - A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
@@ -132,56 +107,7 @@ _Avoid_: Response envelope
- 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. - Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn. - A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
- **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`.
- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs.
- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately.
- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers.
- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property.
- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names.
- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server.
- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently.
- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR.
- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR.
- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime.
- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface.
- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy.
- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors.
- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division.
- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred.
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol.
- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client.
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate.
- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata?
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
- 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.
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply. - When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
@@ -198,23 +124,6 @@ _Avoid_: Response envelope
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority. - **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads. - Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
## Client contract architecture
Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server.
Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases.
Before stabilizing the client API:
- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM.
- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API.
- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract.
- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier.
- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change.
- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests.
- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly.
- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client.
## Example dialogue ## Example dialogue
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?" > **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
+50 -136
View File
@@ -29,12 +29,8 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -63,7 +59,7 @@
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553", "ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
@@ -83,7 +79,6 @@
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1", "vite-plugin-icons-spritesheet": "3.0.1",
@@ -92,7 +87,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@opencode-ai/cli", "name": "@opencode-ai/cli",
"version": "1.17.11", "version": "1.17.10",
"bin": { "bin": {
"lildax": "./bin/lildax.cjs", "lildax": "./bin/lildax.cjs",
}, },
@@ -115,32 +110,9 @@
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
}, },
}, },
"packages/client": {
"name": "@opencode-ai/client",
"dependencies": {
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-beta.83",
},
"optionalPeers": [
"effect",
],
},
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -176,7 +148,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.17.11", "version": "1.17.10",
"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",
@@ -203,7 +175,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.17.11", "version": "1.17.10",
"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",
@@ -225,7 +197,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.17.11", "version": "1.17.10",
"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",
@@ -249,7 +221,7 @@
}, },
"packages/console/support": { "packages/console/support": {
"name": "@opencode-ai/console-support", "name": "@opencode-ai/console-support",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-core": "workspace:*",
@@ -269,7 +241,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.17.11", "version": "1.17.10",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -317,7 +289,6 @@
"ai-gateway-provider": "3.1.2", "ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8", "bun-pty": "0.4.8",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
@@ -363,7 +334,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"effect": "catalog:", "effect": "catalog:",
@@ -417,7 +388,7 @@
}, },
"packages/effect-drizzle-sqlite": { "packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite", "name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -431,7 +402,7 @@
}, },
"packages/effect-sqlite-node": { "packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node", "name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"effect": "catalog:", "effect": "catalog:",
}, },
@@ -443,7 +414,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"@hono/standard-validator": "catalog:", "@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -475,7 +446,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -491,7 +462,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.17.11", "version": "1.17.10",
"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",
@@ -508,21 +479,9 @@
"effect": "4.0.0-beta.83", "effect": "4.0.0-beta.83",
}, },
}, },
"packages/httpapi-codegen": {
"name": "@opencode-ai/httpapi-codegen",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/llm": { "packages/llm": {
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14", "@smithy/eventstream-codec": "4.2.14",
@@ -541,7 +500,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.17.11", "version": "1.17.10",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -671,7 +630,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -688,9 +647,9 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
"peerDependencies": { "peerDependencies": {
"@opentui/core": ">=0.3.4", "@opentui/core": ">=0.4.2",
"@opentui/keymap": ">=0.3.4", "@opentui/keymap": ">=0.4.2",
"@opentui/solid": ">=0.3.4", "@opentui/solid": ">=0.4.2",
}, },
"optionalPeers": [ "optionalPeers": [
"@opentui/core", "@opentui/core",
@@ -731,23 +690,9 @@
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
}, },
}, },
"packages/sdk-next": {
"name": "@opencode-ai/sdk-next",
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/server": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -762,7 +707,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@opencode-ai/server", "name": "@opencode-ai/server",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",
@@ -777,7 +722,7 @@
}, },
"packages/session-ui": { "packages/session-ui": {
"name": "@opencode-ai/session-ui", "name": "@opencode-ai/session-ui",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -821,7 +766,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -834,7 +779,7 @@
}, },
"packages/stats/app": { "packages/stats/app": {
"name": "@opencode-ai/stats-app", "name": "@opencode-ai/stats-app",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*", "@opencode-ai/stats-core": "workspace:*",
@@ -867,7 +812,7 @@
}, },
"packages/stats/core": { "packages/stats/core": {
"name": "@opencode-ai/stats-core", "name": "@opencode-ai/stats-core",
"version": "1.17.11", "version": "1.17.10",
"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",
@@ -886,7 +831,7 @@
}, },
"packages/stats/server": { "packages/stats/server": {
"name": "@opencode-ai/stats-server", "name": "@opencode-ai/stats-server",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"@aws-sdk/client-firehose": "3.933.0", "@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
@@ -927,7 +872,7 @@
}, },
"packages/tui": { "packages/tui": {
"name": "@opencode-ai/tui", "name": "@opencode-ai/tui",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
@@ -954,7 +899,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
@@ -964,6 +909,8 @@
"@solid-primitives/event-listener": "2.4.5", "@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/media": "2.3.3", "@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"diff": "catalog:", "diff": "catalog:",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
@@ -979,33 +926,27 @@
"remeda": "catalog:", "remeda": "catalog:",
"remend": "catalog:", "remend": "catalog:",
"shiki": "catalog:", "shiki": "catalog:",
"solid-js": "catalog:",
"solid-list": "catalog:", "solid-list": "catalog:",
"strip-ansi": "7.1.2", "strip-ansi": "7.1.2",
}, },
"devDependencies": { "devDependencies": {
"@solidjs/meta": "catalog:",
"@tailwindcss/vite": "catalog:", "@tailwindcss/vite": "catalog:",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/katex": "0.16.7", "@types/katex": "0.16.7",
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"solid-js": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1", "vite-plugin-icons-spritesheet": "3.0.1",
"vite-plugin-solid": "catalog:", "vite-plugin-solid": "catalog:",
}, },
"peerDependencies": {
"@solidjs/meta": "^0.29.0",
"solid-js": "^1.9.0",
},
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.17.11", "version": "1.17.10",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -1051,7 +992,6 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
@@ -1080,9 +1020,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.3.4", "@opentui/core": "0.4.2",
"@opentui/keymap": "0.3.4", "@opentui/keymap": "0.4.2",
"@opentui/solid": "0.3.4", "@opentui/solid": "0.4.2",
"@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",
@@ -1459,20 +1399,6 @@
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
"@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="],
"@dnd-kit/collision": ["@dnd-kit/collision@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ=="],
"@dnd-kit/dom": ["@dnd-kit/dom@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw=="],
"@dnd-kit/geometry": ["@dnd-kit/geometry@0.5.0", "", { "dependencies": { "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A=="],
"@dnd-kit/helpers": ["@dnd-kit/helpers@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg=="],
"@dnd-kit/solid": ["@dnd-kit/solid@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-IKDqVZICS0jEeUzpJMIIF61w0WA4zisyx9U7K7Skbmkb/kQSDa3lB0cOc0947RwSO+ALoxytRNOuoNfyOIm3lQ=="],
"@dnd-kit/state": ["@dnd-kit/state@0.5.0", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w=="],
"@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="], "@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
@@ -1921,8 +1847,6 @@
"@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"], "@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"],
"@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"],
"@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"], "@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"],
"@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"], "@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"],
@@ -1949,8 +1873,6 @@
"@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"], "@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"],
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
"@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"], "@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
@@ -1963,8 +1885,6 @@
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"], "@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
"@opencode-ai/sdk-next": ["@opencode-ai/sdk-next@workspace:packages/sdk-next"],
"@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/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
@@ -2013,27 +1933,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.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="], "@opentui/core": ["@opentui/core@0.4.2", "", { "dependencies": { "bun-ffi-structs": "0.2.3", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.2", "@opentui/core-darwin-x64": "0.4.2", "@opentui/core-linux-arm64": "0.4.2", "@opentui/core-linux-arm64-musl": "0.4.2", "@opentui/core-linux-x64": "0.4.2", "@opentui/core-linux-x64-musl": "0.4.2", "@opentui/core-win32-arm64": "0.4.2", "@opentui/core-win32-x64": "0.4.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ulx6RMqftf2fm7Itf9e81GcCDMNY6NAhmnKYhllDOMYD+PxYXR+vomy2bxQNV5ow31RE7s8WQFnb7hWTRUbx2g=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-is+O+sS/l3E9cZXyM9pRF1WhqnE+hYSPYoZkbseR9CthJcaWPGi3R3jUJa1cLj325252jWgxVupnDqFUtKg36w=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ACi42h81DurSeybUAD1XyKT6xmXZcKeTxS54lZFi0CVZh46w0g99vNj8PlQzIFXvvFLT0e0IlRS//eWSWS2zGQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-RjOx2HcjLRtGSy9WrAGSdr5M9SpJuPifPORpImx6Mciovw0ltnE0uoYjIyor82uf6/LExWC7YA2AcAl+YBxayA=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="], "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-heNciL2ngPU+kq1h01PHLsxn6Fr8iqTFtbxSdVbhaY3XihuIjkuXyEhFeuoa1lsXY7Bb2gpWnX5EQVWnZsAuDQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9s0s/ooK+AhWP306By3gu+XhzcVEThC2sqKMPK1nQmGDujQhd+xOrtbtfCVcJSx62UzAovC2VNqypvP8vHByOg=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="], "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cjv6Bv7l3p/KLNJr5RyqCS0FmRlAGJnkA2IK3S+HkHhCOv/O02S1G+DBUY6POnyjp1eNy95vauustApobhdbig=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mfJZrJ0TNPFRZUzXNsxAPe1YdiWsy/vbTl93+yeXGHPI1B8Qnk9V5hpzSxxEyBGhlTHSfGNtgiO+VrrdRC3kZA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-P2oguG3ng3OMjAdasFSA3GhHaQXtzDUsIRDGbzWFOimpZ/zMemidp+JQ0V8V6XwK6Utk5G0aQ03oBaRCoLyYDw=="],
"@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="], "@opentui/keymap": ["@opentui/keymap@0.4.2", "", { "dependencies": { "@opentui/core": "0.4.2" }, "peerDependencies": { "@opentui/react": "0.4.2", "@opentui/solid": "0.4.2", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wxBEFfWgm3feqCRLckWg1JH4tbMJinpyK3yobkLTsWJ7PDsM+fPoFMyQ8ieKVdUL2eP6ELTmHvM1bHKShZ7SUQ=="],
"@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="], "@opentui/solid": ["@opentui/solid@0.4.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zuYXsnrlsMtnXrS7QCYBdPzMtUSonG2LqnJikBR2NjEE2O4zEKvJd48n3eB1igcxjv96tiotTXRNCylYS0SNdQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2309,8 +2229,6 @@
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
"@preact/signals-core": ["@preact/signals-core@1.14.3", "", {}, "sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw=="],
"@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="], "@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="],
"@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="], "@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="],
@@ -3213,7 +3131,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.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], "bun-ffi-structs": ["bun-ffi-structs@0.2.3", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-pgJiXP+hEgFo9qG51J6ItfY4ocs3vniwNzJ9WhoakB3QB2GdzQxX2EXssentPYlB2hOfJrTjO6iIQkWYzUodpg=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
@@ -3805,7 +3723,7 @@
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="], "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
@@ -5319,8 +5237,6 @@
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="], "turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="], "tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
@@ -5573,8 +5489,6 @@
"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=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-JXQ9PAqqRlJtHa8T3ZxqdRRyxC+0ip+2wSnehvKXUbI=", "x86_64-linux": "sha256-Q0CW2wMhVtRM3uILV1kvKAzpQJqBxTp4MLsGlsSoT1s=",
"aarch64-linux": "sha256-nI+RaxDXmAcjhSjCtIvyi292xBEg0E5NXaoyGrJE69s=", "aarch64-linux": "sha256-UHpoDqqV+umG47LENB7DLxbCqvwjTCDjHWIFiv6Vmk8=",
"aarch64-darwin": "sha256-UleKpm7Khf3kibm4BqZJ9bmu0N2kOjNd77g1Bd2tmfw=", "aarch64-darwin": "sha256-rqlFositEiB1wzx6Jcp5yVQlp8LLYBW+79KXmRpXh2Q=",
"x86_64-darwin": "sha256-V4AQH868dOfVhEj1mKTnZlhpZwFqBYsHS28Tx9VXHkE=" "x86_64-darwin": "sha256-BKr4YQio3eP0VLZ0z+oyaM93H8dAbSwmlzxT8PH6W8M="
} }
} }
+4 -5
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.3.4", "@opentui/core": "0.4.2",
"@opentui/keymap": "0.3.4", "@opentui/keymap": "0.4.2",
"@opentui/solid": "0.3.4", "@opentui/solid": "0.4.2",
"@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",
@@ -153,7 +153,6 @@
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch"
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
} }
} }
@@ -1,135 +0,0 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
test("closing the active server's last tab opens the remaining server tab", async ({ page }) => {
const requests: string[] = []
await mockServers(page, requests)
await page.addInitScript(
({ serverB, sessionA, sessionB }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem(
"opencode.global.dat:tabs",
JSON.stringify([
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
{ type: "session", server: serverB, sessionId: sessionB },
]),
)
},
{ serverB, sessionA: sessionA.id, sessionB: sessionB.id },
)
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(hrefA)
await expect(page.getByText(sessionA.title).first()).toBeVisible()
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
await tabA.locator('[data-slot="tab-close"] button').click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
expect(
requests.some((request) => {
const url = new URL(request)
return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory
}),
).toBe(true)
})
test("legacy session routes preserve an existing tab's server", async ({ page }) => {
await mockServers(page, [])
await page.addInitScript(
({ serverB, sessionB }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem(
"opencode.global.dat:tabs",
JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
)
},
{ serverB, sessionB: sessionB.id },
)
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`)
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
function session(id: string, directory: string, title: string) {
return {
id,
slug: id,
projectID: `project-${id}`,
directory,
title,
version: "dev",
time: { created: 1, updated: 1 },
}
}
async function mockServers(page: Page, requests: string[]) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
requests.push(url.toString())
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session") return json(route, [current])
if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
return json(route, {})
if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (url.pathname === "/project" || url.pathname === "/project/current") {
const project = {
id: current.projectID,
worktree: current.directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(route, url.pathname === "/project" ? [project] : project)
}
if (url.pathname === "/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
return json(route, {})
})
}
function json(route: Route, body: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify(body),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
@@ -1,132 +0,0 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/RequestDocks"
const projectID = "proj_request_docks"
const sessionID = "ses_request_docks"
const title = "Request dock regression"
test("shows a pending question dock", async ({ page }) => {
await mockServer(page, {
questions: [
{
id: "question-request",
sessionID,
questions: [
{
header: "Implementation",
question: "Which implementation should be used?",
options: [
{ label: "Minimal", description: "Use the smallest correct change" },
{ label: "Extended", description: "Include additional behavior" },
],
},
],
},
],
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question).toBeVisible()
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
await question.getByRole("radio", { name: /Minimal/ }).click()
const reply = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
)
await question.getByRole("button", { name: "Submit" }).click()
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
})
test("shows a pending permission dock", async ({ page }) => {
await mockServer(page, {
permissions: [
{
id: "permission-request",
sessionID,
permission: "bash",
patterns: ["git status", "git diff"],
metadata: {},
always: [],
},
],
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
await expect(permission).toBeVisible()
await expect(permission.getByText("git status")).toBeVisible()
await expect(permission.getByText("git diff")).toBeVisible()
await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveCount(3)
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
const reply = page.waitForRequest((request) => request.method() === "POST")
await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
expect(request.postDataJSON()).toEqual({ response: "once" })
})
async function mockServer(
page: Page,
requests: {
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
},
) {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "request-docks",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"claude-opus-4-6": {
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
limit: { context: 200_000 },
},
},
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [
{
id: sessionID,
slug: "request-docks",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
permissions: requests.permissions,
questions: requests.questions,
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
}
@@ -1,186 +0,0 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/TodoDockNavigation"
const projectID = "proj_todo_dock_navigation"
const sourceID = "ses_todo_dock_source"
const otherID = "ses_todo_dock_other"
const sourceTitle = "Todo dock animation"
const otherTitle = "Separate session"
const activeTodos = [
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
]
type EventPayload = {
directory: string
payload: Record<string, unknown>
}
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
test.setTimeout(90_000)
const events: EventPayload[] = []
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "todo-dock-navigation",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"claude-opus-4-6": {
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
limit: { context: 200_000 },
},
},
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
todos: (sessionID) => todos[sessionID] ?? [],
})
await configurePage(page)
await page.goto(sessionHref(sourceID))
await expectSessionTitle(page, sourceTitle)
const dock = page.locator('[data-component="session-todo-dock"]')
await expect(dock).toHaveCount(0)
events.push(statusEvent(sourceID, "busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await page.waitForTimeout(700)
const opening = sampleDock(page, 1_000)
todos[sourceID] = activeTodos
events.push(todoEvent(sourceID, activeTodos))
await expect(dock).toBeVisible()
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
await switchSession(page, otherID, otherTitle)
await expect(dock).toHaveCount(0)
const returningOpen = sampleDock(page, 700)
await switchSession(page, sourceID, sourceTitle)
const openSamples = (await returningOpen).filter((sample) => sample.present)
expect(openSamples.length).toBeGreaterThan(0)
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
expect(openSamples[0]!.height).toBeGreaterThan(70)
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
const closing = sampleDock(page, 1_000)
todos[sourceID] = completedTodos
events.push(todoEvent(sourceID, completedTodos))
await expect(dock).toHaveCount(0)
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
todos[sourceID] = []
events.push(todoEvent(sourceID, []))
await switchSession(page, otherID, otherTitle)
const returningEmpty = sampleDock(page, 700)
await switchSession(page, sourceID, sourceTitle)
await expect(dock).toHaveCount(0)
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
})
function session(id: string, title: string, created: number) {
return {
id,
slug: id,
projectID,
directory,
title,
version: "dev",
time: { created, updated: created },
}
}
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
return {
directory,
payload: { type: "session.status", properties: { sessionID, status: { type } } },
}
}
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
return {
directory,
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
}
}
async function configurePage(page: Page) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ directory, dirBase64, server, sessionIDs }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:tabs",
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
)
},
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
)
}
function sessionHref(sessionID: string) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
return `/server/${base64Encode(server)}/session/${sessionID}`
}
async function switchSession(page: Page, sessionID: string, title: string) {
const href = sessionHref(sessionID)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
await expectSessionTitle(page, title)
}
function sampleDock(page: Page, duration: number) {
return page.evaluate(async (duration) => {
const samples: { present: boolean; height: number; opacity: number }[] = []
const start = performance.now()
while (performance.now() - start < duration) {
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
const clip = dock?.parentElement?.parentElement
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
samples.push({
present: !!dock,
height: clip?.getBoundingClientRect().height ?? 0,
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
})
await new Promise(requestAnimationFrame)
}
return samples
}, duration)
}
@@ -1,79 +0,0 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/HiddenTerminalRegression"
const projectID = "proj_hidden_terminal_regression"
const sessionID = "ses_hidden_terminal_regression"
const title = "Hidden terminal regression"
test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "hidden-terminal-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: "hidden-terminal-regression",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.route("**/pty", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }),
}),
)
await page.route("**/pty/pty_hidden_terminal", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await page.keyboard.press("Control+Backquote")
const panel = page.locator("#terminal-panel")
await expect(panel).toHaveAttribute("aria-hidden", "false")
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
await page.keyboard.press("Control+Backquote")
await expect(panel).toHaveAttribute("aria-hidden", "true")
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
await page.setViewportSize({ width: 1200, height: 700 })
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
await page.keyboard.press("Control+Backquote")
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
})
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
+11 -11
View File
@@ -1,6 +1,15 @@
import type { Page, Route } from "@playwright/test" import type { Page, Route } from "@playwright/test"
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"]) const emptyList = new Set([
"/skill",
"/command",
"/lsp",
"/formatter",
"/permission",
"/question",
"/vcs/status",
"/vcs/diff",
])
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"]) const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
export interface MockServerConfig { export interface MockServerConfig {
@@ -14,9 +23,6 @@ export interface MockServerConfig {
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
events?: () => unknown[] events?: () => unknown[]
eventRetry?: number eventRetry?: number
todos?: (sessionID: string) => unknown[]
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
} }
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
@@ -49,10 +55,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 === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
if (path === "/question")
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) 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, [])
@@ -64,9 +66,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
return json(route, session ?? {}) return json(route, session ?? {})
} }
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) { if (messagesMatch) {
+4 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.17.11", "version": "1.17.10",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -17,9 +17,9 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "bun run test:unit && bun run test:browser", "test": "bun run test:unit && bun run test:virtualizer",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src", "test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser", "test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:local": "playwright test", "test:e2e:local": "playwright test",
@@ -38,17 +38,12 @@
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1", "vite-plugin-icons-spritesheet": "3.0.1",
"vite-plugin-solid": "catalog:" "vite-plugin-solid": "catalog:"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -77,7 +72,7 @@
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553", "ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
+47 -93
View File
@@ -16,7 +16,6 @@ import {
type Component, type Component,
createEffect, createEffect,
createMemo, createMemo,
createRenderEffect,
createResource, createResource,
createSignal, createSignal,
ErrorBoundary, ErrorBoundary,
@@ -33,12 +32,12 @@ import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk" import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
import { ServerSyncProvider, useServerSync } from "@/context/server-sync" import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
import { GlobalProvider, useGlobal } 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"
import { LayoutProvider } from "@/context/layout" import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models" import { ModelsProvider } from "@/context/models"
import { NotificationProvider, useNotification } from "@/context/notification" import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission" import { PermissionProvider } from "@/context/permission"
import { PromptProvider } from "@/context/prompt" import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
@@ -52,14 +51,7 @@ import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new" 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 { import { legacySessionHref, requireServerKey, sessionHref } from "./utils/session-route"
legacySessionHref,
legacySessionServer,
requireServerKey,
selectSessionLineage,
sessionHref,
} from "./utils/session-route"
import { isSessionNotFoundError } from "./utils/server-errors"
import Session from "@/pages/session" import Session from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home" import { NewHome, LegacyHome } from "@/pages/home"
@@ -75,15 +67,7 @@ const SessionRoute = () => {
const tabs = useTabs() const tabs = useTabs()
if (params.id && settings.general.newLayoutDesigns()) { if (params.id && settings.general.newLayoutDesigns()) {
const sessionID = params.id return <Navigate href={sessionHref(server.key, params.id)} />
return (
<Show when={tabs.ready()}>
{(_) => {
const persisted = tabs.store.filter((item) => item.type === "session")
return <Navigate href={sessionHref(legacySessionServer(persisted, sessionID, server.key), sessionID)} />
}}
</Show>
)
} }
// 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)
@@ -104,14 +88,14 @@ const SessionRoute = () => {
const TargetSessionRoute = () => { const TargetSessionRoute = () => {
const params = useParams<{ serverKey: string; id: string }>() const params = useParams<{ serverKey: string; id: string }>()
const global = useGlobal() const server = useServer()
const conn = createMemo(() => { const conn = createMemo(() => {
const key = requireServerKey(params.serverKey) const key = requireServerKey(params.serverKey)
return global.servers.list().find((item) => ServerConnection.key(item) === key) return server.list.find((item) => ServerConnection.key(item) === key)
}) })
return ( return (
<Show when={requireServerKey(params.serverKey)} keyed> <Show when={`${params.serverKey}\0${params.id}`} keyed>
<ServerSDKProvider server={conn}> <ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}> <ServerSyncProvider server={conn}>
<ResolvedTargetSessionRoute /> <ResolvedTargetSessionRoute />
@@ -131,15 +115,11 @@ function ResolvedTargetSessionRoute() {
const [resolved] = createResource( const [resolved] = createResource(
() => { () => {
if (cached()) return if (cached()) return
return { id: params.id, server: serverKey(), sync: sync() } return { id: params.id, sync: sync() }
}, },
({ id, server, sync }) => ({ id, sync }) => sync.session.lineage.resolve(id),
sync.session.lineage.resolve(id).catch((error) => {
if (isSessionNotFoundError(error, id)) tabs.removeSessionTab({ server, sessionId: id })
throw error
}),
) )
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved())) const current = createMemo(() => cached() ?? resolved())
const directory = createMemo(() => current()?.session.directory) const directory = createMemo(() => current()?.session.directory)
const targetDirectory = () => directory()! const targetDirectory = () => directory()!
@@ -154,7 +134,7 @@ function ResolvedTargetSessionRoute() {
return ( return (
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}> <TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<Show when={!!current() || resolved.state !== "errored"} fallback={<ErrorPage error={resolved.error} />}> <Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
<Show when={directory()}> <Show when={directory()}>
<Show <Show
when={settings.general.newLayoutDesigns()} when={settings.general.newLayoutDesigns()}
@@ -222,27 +202,25 @@ function DraftRoute() {
} }
function ResolvedDraftRoute(props: { draft: DraftTab }) { function ResolvedDraftRoute(props: { draft: DraftTab }) {
const global = useGlobal() const server = useServer()
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server)) const conn = createMemo(() => server.list.find((item) => ServerConnection.key(item) === props.draft.server))
const directory = () => props.draft.directory const directory = () => props.draft.directory
const serverKey = () => props.draft.server const serverKey = () => props.draft.server
return ( return (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed> <ServerSDKProvider server={conn}>
<ServerSDKProvider server={conn}> <ServerSyncProvider server={conn}>
<ServerSyncProvider server={conn}> <TargetServerScopedProviders directory={directory}>
<TargetServerScopedProviders directory={directory}> <SDKProvider directory={directory}>
<SDKProvider directory={directory}> <DirectoryDataProvider directory={directory} server={serverKey}>
<DirectoryDataProvider directory={directory} server={serverKey}> <DraftProviders>
<DraftProviders> <NewSession />
<NewSession /> </DraftProviders>
</DraftProviders> </DirectoryDataProvider>
</DirectoryDataProvider> </SDKProvider>
</SDKProvider> </TargetServerScopedProviders>
</TargetServerScopedProviders> </ServerSyncProvider>
</ServerSyncProvider> </ServerSDKProvider>
</ServerSDKProvider>
</Show>
) )
} }
@@ -279,11 +257,10 @@ function QueryProvider(props: ParentProps) {
function BodyDesignClass() { function BodyDesignClass() {
const settings = useSettings() const settings = useSettings()
createRenderEffect(() => { createEffect(() => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const enabled = settings.general.newLayoutDesigns() const enabled = settings.general.newLayoutDesigns()
document.body.toggleAttribute("data-new-layout", enabled)
document.body.classList.toggle("text-12-regular", !enabled) document.body.classList.toggle("text-12-regular", !enabled)
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled) document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
document.body.classList.toggle("text-[13px]", enabled) document.body.classList.toggle("text-[13px]", enabled)
@@ -316,7 +293,9 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<PermissionProvider directory={props.directory}> <PermissionProvider directory={props.directory}>
<LayoutProvider> <LayoutProvider>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider> <NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider>{props.children}</ModelsProvider>
</NotificationProvider>
</LayoutProvider> </LayoutProvider>
</PermissionProvider> </PermissionProvider>
) )
@@ -343,23 +322,13 @@ function NewAppLayout(props: ParentProps) {
function TargetServerScopedProviders(props: ServerScopedShellProps) { function TargetServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<PermissionProvider directory={props.directory}> <PermissionProvider directory={props.directory}>
<MarkSessionNotificationsViewed sessionID={props.sessionID} /> <NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider> <ModelsProvider>{props.children}</ModelsProvider>
</NotificationProvider>
</PermissionProvider> </PermissionProvider>
) )
} }
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
const notification = useNotification()
createEffect(() => {
const sessionID = props.sessionID?.()
if (!notification.ready() || !sessionID) return
if (notification.session.unseenCount(sessionID) === 0) return
notification.session.markViewed(sessionID)
})
return null
}
function SessionProviders(props: ParentProps) { function SessionProviders(props: ParentProps) {
return ( return (
<TerminalProvider> <TerminalProvider>
@@ -568,13 +537,11 @@ export function AppInterface(props: {
component={props.router ?? Router} component={props.router ?? Router}
root={(routerProps) => ( root={(routerProps) => (
<TabsProvider> <TabsProvider>
<NotificationProvider> <ServerShell>
<ServerShell> <Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}> <NewAppLayout>{routerProps.children}</NewAppLayout>
<NewAppLayout>{routerProps.children}</NewAppLayout> </Show>
</Show> </ServerShell>
</ServerShell>
</NotificationProvider>
</TabsProvider> </TabsProvider>
)} )}
> >
@@ -602,31 +569,18 @@ function Routes() {
</Route> </Route>
<Show when={settings.general.newLayoutDesigns()}> <Show when={settings.general.newLayoutDesigns()}>
<Route path="/" component={NewHome} /> <Route path="/" component={NewHome} />
<Route path="/:dir/session/:id" component={LegacyTargetSessionRoute} /> <Route
path="/:dir/session/:id"
component={() => {
const server = useServer()
const { id } = useParams()
return <Navigate href={`/server/${server.key}/session/${id}`} />
}}
/>
</Show> </Show>
<Route path="/new-session" component={DraftRoute} /> <Route path="/new-session" component={DraftRoute} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} /> <Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</> </>
) )
} }
function LegacyTargetSessionRoute() {
const server = useServer()
const tabs = useTabs()
const params = useParams<{ id: string }>()
return (
<Show when={tabs.ready()}>
<Navigate
href={sessionHref(
legacySessionServer(
tabs.store.filter((item) => item.type === "session"),
params.id,
server.key,
),
params.id,
)}
/>
</Show>
)
}
@@ -1,22 +0,0 @@
import { describe, expect, test } from "bun:test"
import { newTabTooltipKeybind, reviewTooltipKeybind } from "./command-tooltip-keybind"
describe("command tooltip keybinds", () => {
test("keeps localized review shortcut modifiers", () => {
const command = {
keybind: () => "Ctrl+Maj+R",
keybindParts: () => ["Ctrl", "Maj", "R"],
}
expect(reviewTooltipKeybind(command, (key) => key)).toEqual(["Ctrl", "Maj", "R"])
})
test("uses the configured new-tab shortcut", () => {
const command = {
keybind: () => "Alt+N",
keybindParts: () => ["Alt", "N"],
}
expect(newTabTooltipKeybind(command, (key) => key)).toEqual(["Alt", "N"])
})
})
@@ -1,11 +0,0 @@
type CommandKeybind = {
keybindParts: (id: string) => string[]
}
export function reviewTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) {
return command.keybindParts("review.toggle")
}
export function newTabTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) {
return command.keybindParts("tab.new")
}
+22 -77
View File
@@ -3,7 +3,6 @@ import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
type Mem = Performance & { type Mem = Performance & {
@@ -52,62 +51,31 @@ const bad = (n: number | undefined, limit: number, low = false) => {
const session = (path: string) => path.includes("/session") const session = (path: string) => path.includes("/session")
function Cell(props: { function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
bad?: boolean
dim?: boolean
inline?: boolean
label: string
tip: string
value: string
wide?: boolean
}) {
const content = () => (
<div
classList={{
"flex min-w-0 items-center": true,
"min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
"justify-center text-center": !props.inline,
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
"col-span-2": !!props.wide && !props.inline,
}}
>
<div
classList={{
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
}}
>
{props.label}
</div>
<div
classList={{
"uppercase leading-none font-bold tabular-nums": true,
"text-[11px]": !!props.inline,
"text-[13px] sm:text-[14px]": !props.inline,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div>
)
if (props.inline) {
return (
<TooltipV2 value={props.tip} placement="top">
{content()}
</TooltipV2>
)
}
return ( return (
<Tooltip value={props.tip} placement="top"> <Tooltip value={props.tip} placement="top">
{content()} <div
classList={{
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
"col-span-2": !!props.wide,
}}
>
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
<div
classList={{
"text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div>
</Tooltip> </Tooltip>
) )
} }
export function DebugBar(props: { inline?: boolean } = {}) { export function DebugBar() {
const language = useLanguage() const language = useLanguage()
const location = useLocation() const location = useLocation()
const routing = useIsRouting() const routing = useIsRouting()
@@ -133,7 +101,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
}, },
}) })
const na = () => language.t("debugBar.na").toUpperCase() const na = () => language.t("debugBar.na")
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined) const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
const heapv = () => { const heapv = () => {
const value = heap() const value = heap()
@@ -395,30 +363,15 @@ export function DebugBar(props: { inline?: boolean } = {}) {
return ( return (
<aside <aside
aria-label={language.t("debugBar.ariaLabel")} aria-label={language.t("debugBar.ariaLabel")}
classList={{ 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]"
"pointer-events-auto hidden overflow-hidden text-text-strong md:block": true,
"mt-[-6px] w-full shrink-0 px-3 py-1": !!props.inline,
"fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]":
!props.inline,
}}
> >
<div <div class="grid grid-cols-5 gap-px font-mono">
classList={{
"font-mono": true,
"gap-[9px]": !!props.inline,
"gap-px": !props.inline,
"flex w-full flex-nowrap items-center justify-start": !!props.inline,
"grid-cols-5": !props.inline,
grid: !props.inline,
}}
>
<Cell <Cell
label={language.t("debugBar.nav.label")} label={language.t("debugBar.nav.label")}
tip={language.t("debugBar.nav.tip")} tip={language.t("debugBar.nav.tip")}
value={navv()} value={navv()}
bad={bad(state.nav.dur, 400)} bad={bad(state.nav.dur, 400)}
dim={state.nav.dur === undefined && !state.nav.pending} dim={state.nav.dur === undefined && !state.nav.pending}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.fps.label")} label={language.t("debugBar.fps.label")}
@@ -426,7 +379,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`} value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
bad={bad(state.fps, 50, true)} bad={bad(state.fps, 50, true)}
dim={state.fps === undefined} dim={state.fps === undefined}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.frame.label")} label={language.t("debugBar.frame.label")}
@@ -434,7 +386,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={time(state.gap) ?? na()} value={time(state.gap) ?? na()}
bad={bad(state.gap, 50)} bad={bad(state.gap, 50)}
dim={state.gap === undefined} dim={state.gap === undefined}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.jank.label")} label={language.t("debugBar.jank.label")}
@@ -442,7 +393,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={state.jank === undefined ? na() : `${state.jank}`} value={state.jank === undefined ? na() : `${state.jank}`}
bad={bad(state.jank, 8)} bad={bad(state.jank, 8)}
dim={state.jank === undefined} dim={state.jank === undefined}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.long.label")} label={language.t("debugBar.long.label")}
@@ -450,7 +400,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={longv()} value={longv()}
bad={bad(state.long.block, 200)} bad={bad(state.long.block, 200)}
dim={state.long.count === undefined} dim={state.long.count === undefined}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.delay.label")} label={language.t("debugBar.delay.label")}
@@ -458,7 +407,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={time(state.delay) ?? na()} value={time(state.delay) ?? na()}
bad={bad(state.delay, 100)} bad={bad(state.delay, 100)}
dim={state.delay === undefined} dim={state.delay === undefined}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.inp.label")} label={language.t("debugBar.inp.label")}
@@ -466,7 +414,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={time(state.inp) ?? na()} value={time(state.inp) ?? na()}
bad={bad(state.inp, 200)} bad={bad(state.inp, 200)}
dim={state.inp === undefined} dim={state.inp === undefined}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.cls.label")} label={language.t("debugBar.cls.label")}
@@ -474,7 +421,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={state.cls === undefined ? na() : state.cls.toFixed(2)} value={state.cls === undefined ? na() : state.cls.toFixed(2)}
bad={bad(state.cls, 0.1)} bad={bad(state.cls, 0.1)}
dim={state.cls === undefined} dim={state.cls === undefined}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.mem.label")} label={language.t("debugBar.mem.label")}
@@ -489,7 +435,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={heapv()} value={heapv()}
bad={bad(heap(), 0.8)} bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined} dim={state.heap.used === undefined}
inline={props.inline}
wide wide
/> />
</div> </div>
@@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
@@ -17,16 +17,16 @@ import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
export function DialogConnectProvider(props: { provider: string; directory?: Accessor<string | undefined> }) { export function DialogConnectProvider(props: { provider: string }) {
const dialog = useDialog() const dialog = useDialog()
const serverSync = useServerSync() const serverSync = useServerSync()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const language = useLanguage() const language = useLanguage()
const providers = useProviders(props.directory) const providers = useProviders()
const all = () => { const all = () => {
void import("./dialog-select-provider").then((x) => { void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider directory={props.directory} />) dialog.show(() => <x.DialogSelectProvider />)
}) })
} }
@@ -65,7 +65,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
const [store, setStore] = createStore({ const [store, setStore] = createStore({
methodIndex: undefined as undefined | number, methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization, authorization: undefined as undefined | ProviderAuthAuthorization,
promptInputs: undefined as undefined | Record<string, string>,
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
@@ -74,7 +73,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
| { type: "method.select"; index: number } | { type: "method.select"; index: number }
| { type: "method.reset" } | { type: "method.reset" }
| { type: "auth.prompt" } | { type: "auth.prompt" }
| { type: "auth.inputs"; inputs: Record<string, string> }
| { type: "auth.pending" } | { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization } | { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.error"; error: string } | { type: "auth.error"; error: string }
@@ -85,7 +83,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
if (action.type === "method.select") { if (action.type === "method.select") {
draft.methodIndex = action.index draft.methodIndex = action.index
draft.authorization = undefined draft.authorization = undefined
draft.promptInputs = undefined
draft.state = undefined draft.state = undefined
draft.error = undefined draft.error = undefined
return return
@@ -93,7 +90,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
if (action.type === "method.reset") { if (action.type === "method.reset") {
draft.methodIndex = undefined draft.methodIndex = undefined
draft.authorization = undefined draft.authorization = undefined
draft.promptInputs = undefined
draft.state = undefined draft.state = undefined
draft.error = undefined draft.error = undefined
return return
@@ -103,12 +99,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
draft.error = undefined draft.error = undefined
return return
} }
if (action.type === "auth.inputs") {
draft.promptInputs = action.inputs
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.pending") { if (action.type === "auth.pending") {
draft.state = "pending" draft.state = "pending"
draft.error = undefined draft.error = undefined
@@ -161,15 +151,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
const method = methods()[index] const method = methods()[index]
dispatch({ type: "method.select", index }) dispatch({ type: "method.select", index })
if (method.type === "api" && method.prompts?.length) {
if (!inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.inputs", inputs })
return
}
if (method.type === "oauth") { if (method.type === "oauth") {
if (method.prompts?.length && !inputs) { if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" }) dispatch({ type: "auth.prompt" })
@@ -209,7 +190,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
} }
} }
function AuthPromptsView() { function OAuthPromptsView() {
const [formStore, setFormStore] = createStore({ const [formStore, setFormStore] = createStore({
value: {} as Record<string, string>, value: {} as Record<string, string>,
index: 0, index: 0,
@@ -217,7 +198,8 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => { const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
const value = method() const value = method()
return value?.prompts ?? [] if (value?.type !== "oauth") return []
return value.prompts ?? []
}) })
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => { const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true if (!prompt.when) return true
@@ -248,10 +230,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
setFormStore("index", next) setFormStore("index", next)
return return
} }
if (method()?.type === "api") {
dispatch({ type: "auth.inputs", inputs: value })
return
}
await selectMethod(store.methodIndex, value) await selectMethod(store.methodIndex, value)
} }
@@ -436,7 +414,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
auth: { auth: {
type: "api", type: "api",
key: apiKey, key: apiKey,
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
}, },
}) })
await complete() await complete()
@@ -645,7 +622,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
</div> </div>
</Match> </Match>
<Match when={store.state === "prompt"}> <Match when={store.state === "prompt"}>
<AuthPromptsView /> <OAuthPromptsView />
</Match> </Match>
<Match when={store.state === "error"}> <Match when={store.state === "error"}>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
@@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { type Accessor, batch, For } from "solid-js" import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
@@ -17,7 +17,6 @@ import { DialogSelectProvider } from "./dialog-select-provider"
type Props = { type Props = {
back?: "providers" | "close" back?: "providers" | "close"
directory?: Accessor<string | undefined>
} }
export function DialogCustomProvider(props: Props) { export function DialogCustomProvider(props: Props) {
@@ -41,7 +40,7 @@ export function DialogCustomProvider(props: Props) {
dialog.close() dialog.close()
return return
} }
dialog.show(() => <DialogSelectProvider directory={props.directory} />) dialog.show(() => <DialogSelectProvider />)
} }
const addModel = () => { const addModel = () => {
@@ -9,16 +9,14 @@ import { popularProviders } from "@/hooks/use-providers"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectProvider } from "./dialog-select-provider"
import { decode64 } from "@/utils/base64"
export const DialogManageModels: Component = () => { export const DialogManageModels: Component = () => {
const local = useLocal() const local = useLocal()
const language = useLanguage() const language = useLanguage()
const dialog = useDialog() const dialog = useDialog()
const directory = () => decode64(local.slug())
const handleConnectProvider = () => { const handleConnectProvider = () => {
dialog.show(() => <DialogSelectProvider directory={directory} />) dialog.show(() => <DialogSelectProvider />)
} }
const providerRank = (id: string) => popularProviders.indexOf(id) const providerRank = (id: string) => popularProviders.indexOf(id)
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
@@ -1,6 +1,6 @@
import "@pierre/trees/web-components" import "@pierre/trees/web-components"
import { FileTree } from "@pierre/trees" import { FileTree } from "@pierre/trees"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -27,7 +27,6 @@ import {
pickerRoot, pickerRoot,
} from "./directory-picker-domain" } from "./directory-picker-domain"
import "./dialog-select-directory-v2.css" import "./dialog-select-directory-v2.css"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
interface DialogSelectDirectoryV2Props { interface DialogSelectDirectoryV2Props {
title?: string title?: string
@@ -267,12 +266,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
onCleanup(() => tree?.cleanUp()) onCleanup(() => tree?.cleanUp())
return ( return (
<Dialog size="large" class="directory-picker-v2"> <Dialog title={props.title ?? language.t("command.project.open")} size="large" class="directory-picker-v2">
<DialogHeader> <div class="directory-picker-v2-body">
<DialogTitle>{props.title ?? language.t("command.project.open")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="directory-picker-v2-body pt-4!">
<div class="directory-picker-v2-path" ref={pathArea}> <div class="directory-picker-v2-path" ref={pathArea}>
<TextInputV2 <TextInputV2
value={input()} value={input()}
@@ -354,7 +349,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
</Show> </Show>
</div> </div>
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div> <div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
</DialogBody> </div>
<DialogFooter> <DialogFooter>
<ButtonV2 variant="neutral" onClick={() => dialog.close()}> <ButtonV2 variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")} {language.t("common.cancel")}
@@ -15,6 +15,7 @@ import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
@@ -271,6 +272,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const server = useServer()
const settings = useSettings() const settings = useSettings()
const layout = useLayout() const layout = useLayout()
const file = useFile() const file = useFile()
@@ -391,10 +393,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
state.cleanup?.() state.cleanup?.()
}) })
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) { if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns() && server.current) {
return ( return (
<DialogSelectFileV2 <DialogSelectFileV2
server={serverSDK().server} server={server.current}
mode="file" mode="file"
start={projectDirectory()} start={projectDirectory()}
title={language.t("session.header.searchFiles")} title={language.t("session.header.searchFiles")}
@@ -10,27 +10,24 @@ import { useLocal } from "@/context/local"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { ModelTooltip } from "./model-tooltip" import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
type ModelState = ReturnType<typeof useLocal>["model"] type ModelState = ReturnType<typeof useLocal>["model"]
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => { export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
const local = useLocal() const model = props.model ?? useLocal().model
const model = props.model ?? local.model
const dialog = useDialog() const dialog = useDialog()
const directory = () => decode64(local.slug()) const providers = useProviders()
const providers = useProviders(directory)
const language = useLanguage() const language = useLanguage()
const connect = (provider: string) => { const connect = (provider: string) => {
void import("./dialog-connect-provider").then((x) => { void import("./dialog-connect-provider").then((x) => {
dialog.show(() => <x.DialogConnectProvider provider={provider} directory={directory} />) dialog.show(() => <x.DialogConnectProvider provider={provider} />)
}) })
} }
const all = () => { const all = () => {
void import("./dialog-select-provider").then((x) => { void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider directory={directory} />) dialog.show(() => <x.DialogSelectProvider />)
}) })
} }
@@ -12,7 +12,6 @@ import { List } from "@opencode-ai/ui/list"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ModelTooltip } from "./model-tooltip" import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
const isFree = (provider: string, cost: { input: number } | undefined) => const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0) provider === "opencode" && (!cost || cost.input === 0)
@@ -59,7 +58,6 @@ const ModelList: Component<{
class="w-full" class="w-full"
placement="right-start" placement="right-start"
gutter={12} gutter={12}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />} value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
> >
{node} {node}
@@ -106,8 +104,6 @@ export function ModelSelectorPopover(props: {
dismiss: null, dismiss: null,
}) })
const dialog = useDialog() const dialog = useDialog()
const local = useLocal()
const directory = () => decode64(local.slug())
const close = (dismiss: Dismiss) => { const close = (dismiss: Dismiss) => {
setStore("dismiss", dismiss) setStore("dismiss", dismiss)
@@ -124,7 +120,7 @@ export function ModelSelectorPopover(props: {
const handleConnectProvider = () => { const handleConnectProvider = () => {
close("provider") close("provider")
void import("./dialog-select-provider").then((x) => { void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider directory={directory} />) dialog.show(() => <x.DialogSelectProvider />)
}) })
} }
const language = useLanguage() const language = useLanguage()
@@ -203,12 +199,10 @@ export function ModelSelectorPopover(props: {
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => { export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const local = useLocal()
const directory = () => decode64(local.slug())
const provider = () => { const provider = () => {
void import("./dialog-select-provider").then((x) => { void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider directory={directory} />) dialog.show(() => <x.DialogSelectProvider />)
}) })
} }
@@ -1,4 +1,4 @@
import { type Accessor, Component, Show } from "solid-js" import { Component, Show } from "solid-js"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@@ -11,9 +11,9 @@ import { DialogCustomProvider } from "./dialog-custom-provider"
const CUSTOM_ID = "_custom" const CUSTOM_ID = "_custom"
export const DialogSelectProvider: Component<{ directory?: Accessor<string | undefined> }> = (props) => { export const DialogSelectProvider: Component = () => {
const dialog = useDialog() const dialog = useDialog()
const providers = useProviders(props.directory) const providers = useProviders()
const language = useLanguage() const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular") const popularGroup = () => language.t("dialog.provider.group.popular")
@@ -56,10 +56,10 @@ export const DialogSelectProvider: Component<{ directory?: Accessor<string | und
onSelect={(x) => { onSelect={(x) => {
if (!x) return if (!x) return
if (x.id === CUSTOM_ID) { if (x.id === CUSTOM_ID) {
dialog.show(() => <DialogCustomProvider back="providers" directory={props.directory} />) dialog.show(() => <DialogCustomProvider back="providers" />)
return return
} }
dialog.show(() => <DialogConnectProvider provider={x.id} directory={props.directory} />) dialog.show(() => <DialogConnectProvider provider={x.id} />)
}} }}
> >
{(i) => ( {(i) => (
@@ -1,27 +1,11 @@
// @ts-nocheck // @ts-nocheck
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { Todo } from "@opencode-ai/sdk/v2"
import { createPromptState } from "@/context/prompt" import { createPromptState } from "@/context/prompt"
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
import { createPromptInputHistory, PromptInput } from "./prompt-input" import { createPromptInputHistory, PromptInput } from "./prompt-input"
function createPromptInputStoryRuntime() {
const state = createPromptState()
return {
state,
history: createPromptInputHistory(),
submission: {
abort() {},
handleSubmit(event: Event) {
event.preventDefault()
state.reset()
},
},
}
}
function PromptInputExample() { function PromptInputExample() {
const input = createPromptInputStoryRuntime() const state = createPromptState()
const history = createPromptInputHistory()
const [controls, setControls] = createStore({ const [controls, setControls] = createStore({
agent: "build", agent: "build",
variant: undefined as string | undefined, variant: undefined as string | undefined,
@@ -38,6 +22,13 @@ function PromptInputExample() {
set: (variant?: string) => setControls("variant", variant), set: (variant?: string) => setControls("variant", variant),
}, },
} }
const submission = {
abort() {},
handleSubmit(event: Event) {
event.preventDefault()
state.reset()
},
}
const inputControls = { const inputControls = {
agents: { agents: {
available: [{ name: "review", hidden: false, mode: "subagent" }], available: [{ name: "review", hidden: false, mode: "subagent" }],
@@ -54,6 +45,12 @@ function PromptInputExample() {
paid: true, paid: true,
loading: false, loading: false,
}, },
projects: {
available: [{ name: "Story project", worktree: "/tmp/story", sandboxes: [] }],
directory: "/tmp/story",
select() {},
add() {},
},
session: { session: {
id: "story-session", id: "story-session",
tabs: { tabs: {
@@ -72,7 +69,7 @@ function PromptInputExample() {
const addReviewComment = () => { const addReviewComment = () => {
const comment = controls.comments + 1 const comment = controls.comments + 1
setControls("comments", comment) setControls("comments", comment)
input.state.context.add({ state.context.add({
type: "file", type: "file",
path: "src/components/prompt-input.tsx", path: "src/components/prompt-input.tsx",
selection: { selection: {
@@ -90,7 +87,7 @@ function PromptInputExample() {
return ( return (
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<PromptInput controls={inputControls} {...input} /> <PromptInput controls={inputControls} state={state} history={history} submission={submission} />
<div> <div>
<button <button
type="button" type="button"
@@ -104,94 +101,6 @@ function PromptInputExample() {
) )
} }
const todos: Todo[] = [
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
]
function PromptInputWithOpenDock() {
const input = createPromptInputStoryRuntime()
const [controls, setControls] = createStore({
agent: "build",
activeTab: undefined as string | undefined,
todoCollapsed: false,
})
const inputControls = {
agents: {
available: [],
options: ["build"],
get current() {
return controls.agent
},
loading: false,
visible: true,
select: (agent?: string) => setControls("agent", agent ?? "build"),
},
model: {
selection: {
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
variant: { list: () => [], current: () => undefined, set: () => {} },
},
paid: true,
loading: false,
},
session: {
id: "story-session",
tabs: {
active: () => controls.activeTab,
all: () => [],
open: () => {},
setActive: (tab: string) => setControls("activeTab", tab),
},
reviewPanel: { opened: () => false, open: () => {} },
},
newLayoutDesigns: true,
}
const state = {
blocked: () => false,
questionRequest: () => undefined,
permissionRequest: () => undefined,
permissionResponding: () => false,
decide: () => {},
todos: () => todos,
dock: () => true,
closing: () => false,
opening: () => false,
}
return (
<SessionComposerRegion
controller={createSessionComposerRegionController({
state,
sessionKey: () => "story-session",
sessionID: () => "story-session",
prompt: input.state,
ready: () => true,
centered: () => false,
todo: {
collapsed: () => controls.todoCollapsed,
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
},
followup: () => undefined,
revert: () => undefined,
onResponseSubmit: () => {},
openParent: () => {},
setPromptRef: () => {},
setDockRef: () => {},
})}
promptInput={
<PromptInput
controls={inputControls}
{...input}
ref={() => {}}
newSessionWorktree=""
onNewSessionWorktreeReset={() => {}}
/>
}
/>
)
}
export default { export default {
title: "App/PromptInput", title: "App/PromptInput",
id: "app-prompt-input", id: "app-prompt-input",
@@ -206,12 +115,3 @@ export const Basic = {
</div> </div>
), ),
} }
export const DockAlreadyOpen = {
render: () => (
<div class="pt-10">
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
<PromptInputWithOpenDock />
</div>
),
}
+266 -89
View File
@@ -4,6 +4,8 @@ import {
createEffect, createEffect,
on, on,
Component, Component,
splitProps,
For,
Show, Show,
onCleanup, onCleanup,
createMemo, createMemo,
@@ -11,8 +13,10 @@ import {
createResource, createResource,
Switch, Switch,
Match, Match,
type ComponentProps,
type JSX, type JSX,
} from "solid-js" } from "solid-js"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { useLocal } from "@/context/local" import type { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
@@ -32,11 +36,9 @@ import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon } from "@opencode-ai/ui/icon" import { Icon, type IconProps } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -65,9 +67,10 @@ import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder" import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { pathKey } from "@/utils/path-key"
import { displayName } from "@/pages/layout/helpers"
export type PromptInputState = ReturnType<typeof usePrompt> export type PromptInputState = ReturnType<typeof usePrompt>
@@ -95,6 +98,12 @@ export type PromptInputControls = {
paid: boolean paid: boolean
loading: boolean loading: boolean
} }
projects: {
available: { name?: string; worktree: string; sandboxes?: string[] }[]
directory: string
select: (worktree: string) => void
add: (title: string) => void
}
session: { session: {
id?: string id?: string
tabs: { tabs: {
@@ -165,7 +174,6 @@ export interface PromptInputProps {
onQueue?: (draft: FollowupDraft) => void onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void onAbort?: () => void
onSubmit?: () => void onSubmit?: () => void
toolbar?: JSX.Element
} }
const EXAMPLES = [ const EXAMPLES = [
@@ -214,6 +222,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let fileInputRef: HTMLInputElement | undefined let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement
let projectSearchRef: HTMLInputElement | undefined
const mirror = { input: false } const mirror = { input: false }
const inset = 56 const inset = 56
@@ -337,10 +346,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
) )
const [store, setStore] = createPromptInputTransientState( const [store, setStore] = createStore<{
() => prompt.capture(), popover: "at" | "slash" | null
Math.floor(Math.random() * EXAMPLES.length), historyIndex: number
) savedPrompt: PromptHistoryEntry | null
placeholder: number
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
variantOpen: boolean
}>({
popover: null,
historyIndex: -1,
savedPrompt: null as PromptHistoryEntry | null,
placeholder: Math.floor(Math.random() * EXAMPLES.length),
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
const [picker, setPicker] = createStore({
projectOpen: false,
projectSearch: "",
})
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const motion = (value: number) => ({ const motion = (value: number) => ({
opacity: value, opacity: value,
@@ -791,7 +820,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const scrollSlashActiveIntoView = () => { // Auto-scroll active command into view when navigating with keyboard
createEffect(() => {
const activeId = slashActive() const activeId = slashActive()
if (!activeId || !slashPopoverRef) return if (!activeId || !slashPopoverRef) return
@@ -799,7 +829,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`)
element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
}) })
} })
const selectPopoverActive = () => { const selectPopoverActive = () => {
if (store.popover === "at") { if (store.popover === "at") {
const items = atFlat() const items = atFlat()
@@ -1286,9 +1316,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
if (store.popover === "slash") { if (store.popover === "slash") {
slashOnKeyDown(event) slashOnKeyDown(event)
if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) {
scrollSlashActiveIntoView()
}
} }
event.preventDefault() event.preventDefault()
return return
@@ -1347,9 +1374,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const agentsLoading = () => props.controls.agents.loading const agentsLoading = () => props.controls.agents.loading
const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading()) const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
const providersLoading = () => props.controls.model.loading const providersLoading = () => props.controls.model.loading
const providersShouldFadeIn = createMemo<boolean>((prev) => prev ?? providersLoading()) const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
const [promptReady] = createResource( const [promptReady] = createResource(
() => prompt.ready.promise, () => prompt.ready.promise,
@@ -1363,10 +1390,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const modelControlState = createMemo<ComposerModelControlState>(() => ({ const modelControlState = createMemo<ComposerModelControlState>(() => ({
loading: providersLoading(), loading: providersLoading(),
shouldAnimate: providersShouldFadeIn(),
paid: props.controls.model.paid, paid: props.controls.model.paid,
title: language.t("command.model.choose"), title: language.t("command.model.choose"),
keybind: command.keybindParts("model.choose"), keybind: command.keybind("model.choose"),
model: props.controls.model.selection, model: props.controls.model.selection,
providerID: props.controls.model.selection.current()?.provider?.id, providerID: props.controls.model.selection.current()?.provider?.id,
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"), modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
@@ -1380,10 +1406,75 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})) }))
const newSession = () => props.variant === "new-session" const newSession = () => props.variant === "new-session"
const projects = createMemo(() => props.controls.projects.available)
const projectForDirectory = (directory: string | undefined) => {
if (!directory) return
const key = pathKey(directory)
return projects().find(
(project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
)
}
const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory))
const projectResults = createMemo(() => {
const search = picker.projectSearch.trim().toLowerCase()
if (!search) return projects()
return projects().filter((project) => displayName(project).toLowerCase().includes(search))
})
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0) const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
const selectProject = (worktree: string) => {
setPicker({
projectOpen: false,
projectSearch: "",
})
if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "")) {
restoreFocus()
return
}
props.controls.projects.select(worktree)
restoreFocus()
}
const addProject = () => {
props.controls.projects.add(language.t("command.project.open"))
}
const projectPickerState = createMemo<ComposerPickerState>(() => ({
open: picker.projectOpen,
trigger: {
action: "prompt-project",
icon: "folder",
label: selectedProject() ? displayName(selectedProject()!) : language.t("session.new.project.new"),
class: "max-w-[203px]",
style: control(),
onPress: () => setPicker("projectOpen", true),
},
search: picker.projectSearch,
searchPlaceholder: language.t("session.new.project.search"),
clearLabel: language.t("common.clear"),
items: projectResults().map((project) => ({
icon: "folder",
label: displayName(project),
selected: selectedProject()?.worktree === project.worktree,
onSelect: () => selectProject(project.worktree),
})),
action: {
icon: "plus",
label: language.t("session.new.project.add"),
onSelect: () => {
setPicker("projectOpen", false)
void addProject()
},
},
onOpenChange: (open) => {
setPicker("projectOpen", open)
if (open) requestAnimationFrame(() => projectSearchRef?.focus())
},
onSearchInput: (value) => setPicker("projectSearch", value),
onSearchClear: () => setPicker("projectSearch", ""),
searchRef: (el) => (projectSearchRef = el),
}))
const agentControlState = createMemo<ComposerAgentControlState>(() => ({ const agentControlState = createMemo<ComposerAgentControlState>(() => ({
title: language.t("command.agent.cycle"), title: language.t("command.agent.cycle"),
keybind: command.keybindParts("agent.cycle"), keybind: command.keybind("agent.cycle"),
options: props.controls.agents.options, options: props.controls.agents.options,
current: props.controls.agents.current, current: props.controls.agents.current,
style: control(), style: control(),
@@ -1392,6 +1483,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
restoreFocus() restoreFocus()
}, },
})) }))
const newProjectTriggerState = createMemo<ComposerPickerTriggerState>(() => ({
action: "prompt-project",
icon: "folder-add-left",
label: language.t("session.new.project.new"),
class: "max-w-[160px]",
style: control(),
onPress: () => void addProject(),
}))
return ( return (
<div class="relative size-full flex flex-col gap-0"> <div class="relative size-full flex flex-col gap-0">
{(promptReady(), null)} {(promptReady(), null)}
@@ -1408,8 +1508,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setSlashActive={setSlashActive} setSlashActive={setSlashActive}
onSlashSelect={handleSlashSelect} onSlashSelect={handleSlashSelect}
commandKeybind={command.keybind} commandKeybind={command.keybind}
commandKeybindParts={command.keybindParts}
newLayoutDesigns={props.controls.newLayoutDesigns}
t={(key) => language.t(key as Parameters<typeof language.t>[0])} t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/> />
<Switch> <Switch>
@@ -1503,14 +1601,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="flex h-11 items-center px-2"> <div class="flex h-11 items-center px-2">
<div class="flex min-w-0 flex-1 items-center gap-0"> <div class="flex min-w-0 flex-1 items-center gap-0">
{fileAttachmentInput()} {fileAttachmentInput()}
<TooltipV2 <TooltipKeybind
placement="top" placement="top"
value={ title={language.t("prompt.action.attachFile")}
<> keybind={command.keybind("file.attach")}
{language.t("prompt.action.attachFile")}
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
</>
}
> >
<IconButton <IconButton
data-action="prompt-attach" data-action="prompt-attach"
@@ -1524,30 +1618,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
tabIndex={store.mode === "normal" ? undefined : -1} tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")} aria-label={language.t("prompt.action.attachFile")}
/> />
</TooltipV2> </TooltipKeybind>
<Show when={showAgentControl()}> <Show when={showAgentControl()}>
<ComposerAgentControl state={agentControlState()} /> <ComposerAgentControl state={agentControlState()} />
</Show> </Show>
{props.toolbar} <Show when={newSession() && !selectedProject()}>
<ComposerPickerTrigger state={newProjectTriggerState()} />
</Show>
<ComposerModelControl state={modelControlState()} /> <ComposerModelControl state={modelControlState()} />
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}> <Show when={store.mode !== "shell" && showVariantControl()}>
<div <div
data-component="prompt-variant-control" data-component="prompt-variant-control"
classList={{ classList={{
"animate-in fade-in": providersShouldFadeIn(),
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block": "hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
!props.controls.model.selection.variant.current() && !store.variantOpen, !props.controls.model.selection.variant.current() && !store.variantOpen,
}} }}
> >
<TooltipV2 <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
value={ title={language.t("command.model.variant.cycle")}
<> keybind={command.keybind("model.variant.cycle")}
{language.t("command.model.variant.cycle")}
<KeybindV2 keys={command.keybindParts("model.variant.cycle")} variant="neutral" />
</>
}
> >
<Select <Select
size="normal" size="normal"
@@ -1565,11 +1656,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
triggerProps={{ "data-action": "prompt-model-variant" }} triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost" variant="ghost"
/> />
</TooltipV2> </TooltipKeybind>
</div> </div>
</Show> </Show>
</div> </div>
<TooltipV2 placement="top" inactive={!working() && blank()} value={tip()}> <Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton <IconButton
data-action="prompt-submit" data-action="prompt-submit"
type="submit" type="submit"
@@ -1584,9 +1675,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}} }}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/> />
</TooltipV2> </Tooltip>
</div> </div>
</DockShellForm> </DockShellForm>
<Show when={newSession() && selectedProject()}>
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
<ComposerPicker state={projectPickerState()} />
</div>
</Show>
</div> </div>
</Match> </Match>
<Match when> <Match when>
@@ -1781,7 +1877,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={!agentsLoading()}> <Show when={!agentsLoading()}>
<div <div
data-component="prompt-agent-control" data-component="prompt-agent-control"
classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }} style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
> >
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
@@ -1810,7 +1906,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={store.mode !== "shell"}> <Show when={store.mode !== "shell"}>
<div <div
data-component="prompt-model-control" data-component="prompt-model-control"
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }} style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
> >
<Show <Show
when={props.controls.model.paid} when={props.controls.model.paid}
@@ -1889,7 +1985,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={showVariantControl()}> <Show when={showVariantControl()}>
<div <div
data-component="prompt-variant-control" data-component="prompt-variant-control"
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }} style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
> >
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
@@ -1928,9 +2024,40 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
) )
} }
type ComposerPickerItemState = {
icon: IconProps["name"]
label: string
selected?: boolean
onSelect: () => void
}
type ComposerPickerTriggerState = {
action: string
icon?: IconProps["name"]
label: string
class?: string
style: JSX.CSSProperties | undefined
onPress: () => void
}
type ComposerPickerState = {
open: boolean
trigger: ComposerPickerTriggerState
search: string
searchPlaceholder: string
clearLabel: string
items: ComposerPickerItemState[]
action: ComposerPickerItemState
listClass?: string
searchRef: (el: HTMLInputElement) => void
onOpenChange: (open: boolean) => void
onSearchInput: (value: string) => void
onSearchClear: () => void
}
type ComposerAgentControlState = { type ComposerAgentControlState = {
title: string title: string
keybind: string[] keybind: string
options: string[] options: string[]
current: string current: string
style: JSX.CSSProperties | undefined style: JSX.CSSProperties | undefined
@@ -1939,10 +2066,9 @@ type ComposerAgentControlState = {
type ComposerModelControlState = { type ComposerModelControlState = {
loading: boolean loading: boolean
shouldAnimate: boolean
paid: boolean paid: boolean
title: string title: string
keybind: string[] keybind: string
model: ReturnType<typeof useLocal>["model"] model: ReturnType<typeof useLocal>["model"]
providerID?: string providerID?: string
modelName: string modelName: string
@@ -1951,22 +2077,97 @@ type ComposerModelControlState = {
onUnpaidClick: () => void onUnpaidClick: () => void
} }
function ComposerPickerTrigger(props: ComponentProps<"button"> & { state: ComposerPickerTriggerState }) {
const [local, rest] = splitProps(props, ["state", "class", "style", "onClick"])
return (
<button
{...rest}
data-action={local.state.action}
type="button"
class={`flex h-7 min-w-0 items-center gap-1.5 rounded px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none ${local.state.class ?? ""}`}
style={local.state.style}
onClick={() => local.state.onPress()}
>
<Show when={local.state.icon}>
{(icon) => <Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />}
</Show>
<span class="min-w-0 truncate leading-5">{local.state.label}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</button>
)
}
function ComposerPickerMenuItem(props: { state: ComposerPickerItemState }) {
return (
<button
type="button"
class="flex h-7 w-full items-center gap-2 rounded px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={props.state.onSelect}
>
<Icon name={props.state.icon} size="small" class="shrink-0 text-v2-icon-icon-base" />
<span class="min-w-0 flex-1 truncate leading-5">{props.state.label}</span>
<Show when={props.state.selected}>
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
)
}
function ComposerPicker(props: { state: ComposerPickerState }) {
return (
<KobaltePopover
open={props.state.open}
placement="bottom-start"
gutter={4}
modal={false}
onOpenChange={props.state.onOpenChange}
>
<KobaltePopover.Trigger as={ComposerPickerTrigger} state={props.state.trigger} />
<KobaltePopover.Portal>
<KobaltePopover.Content
class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div class={`flex flex-col p-0.5 ${props.state.listClass ?? ""}`}>
<div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={props.state.searchRef}
value={props.state.search}
placeholder={props.state.searchPlaceholder}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => props.state.onSearchInput(event.currentTarget.value)}
/>
<Show when={props.state.search.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onClick={props.state.onSearchClear}
aria-label={props.state.clearLabel}
>
<Icon name="close-small" size="small" />
</button>
</Show>
</div>
<For each={props.state.items}>{(item) => <ComposerPickerMenuItem state={item} />}</For>
</div>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<ComposerPickerMenuItem state={props.state.action} />
</div>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
)
}
function ComposerAgentControl(props: { state: ComposerAgentControlState }) { function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
return ( return (
<div class="relative"> <div class="relative">
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted"> <div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
<Icon name="sliders" size="small" /> <Icon name="sliders" size="small" />
</div> </div>
<TooltipV2 <TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
placement="top"
gutter={4}
value={
<>
{props.state.title}
<KeybindV2 keys={props.state.keybind} variant="neutral" />
</>
}
>
<Select <Select
size="normal" size="normal"
options={props.state.options} options={props.state.options}
@@ -1978,7 +2179,7 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
triggerProps={{ "data-action": "prompt-agent" }} triggerProps={{ "data-action": "prompt-agent" }}
variant="ghost" variant="ghost"
/> />
</TooltipV2> </TooltipKeybind>
</div> </div>
) )
} }
@@ -1989,23 +2190,13 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
<Show <Show
when={props.state.paid} when={props.state.paid}
fallback={ fallback={
<TooltipV2 <TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
placement="top"
gutter={4}
value={
<>
{props.state.title}
<KeybindV2 keys={props.state.keybind} variant="neutral" />
</>
}
>
<Button <Button
data-action="prompt-model" data-action="prompt-model"
as="div" as="div"
variant="ghost" variant="ghost"
size="normal" size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group" class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
style={props.state.style} style={props.state.style}
onClick={props.state.onUnpaidClick} onClick={props.state.onUnpaidClick}
> >
@@ -2019,23 +2210,12 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
)} )}
</Show> </Show>
<span class="truncate">{props.state.modelName}</span> <span class="truncate">{props.state.modelName}</span>
<span class="-ml-1 shrink-0 flex size-fit"> <Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
</Button> </Button>
</TooltipV2> </TooltipKeybind>
} }
> >
<TooltipV2 <TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
placement="top"
gutter={4}
value={
<>
{props.state.title}
<KeybindV2 keys={props.state.keybind} variant="neutral" />
</>
}
>
<ModelSelectorPopover <ModelSelectorPopover
model={props.state.model} model={props.state.model}
triggerAs={Button} triggerAs={Button}
@@ -2045,7 +2225,6 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
style: props.state.style, style: props.state.style,
class: class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group", "min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
classList: { "animate-in fade-in": props.state.shouldAnimate },
"data-action": "prompt-model", "data-action": "prompt-model",
}} }}
onClose={props.state.onClose} onClose={props.state.onClose}
@@ -2060,11 +2239,9 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
)} )}
</Show> </Show>
<span class="truncate">{props.state.modelName}</span> <span class="truncate">{props.state.modelName}</span>
<span class="-ml-1 shrink-0 flex size-fit"> <Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
</ModelSelectorPopover> </ModelSelectorPopover>
</TooltipV2> </TooltipKeybind>
</Show> </Show>
</Show> </Show>
) )
@@ -25,19 +25,6 @@ function dataUrl(file: File, mime: string) {
}) })
} }
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
type PromptAttachmentsCoreInput = {
capture: () => PromptTarget
editor: () => HTMLDivElement | undefined
focusEditor?: () => void
addPart?: (part: ContentPart) => boolean
warn?: () => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
}
type PromptAttachmentsInput = { type PromptAttachmentsInput = {
prompt: ReturnType<typeof usePrompt> prompt: ReturnType<typeof usePrompt>
editor: () => HTMLDivElement | undefined editor: () => HTMLDivElement | undefined
@@ -49,22 +36,27 @@ type PromptAttachmentsInput = {
getPathForFile?: (file: File) => string getPathForFile?: (file: File) => string
} }
export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) { export function createPromptAttachments(input: PromptAttachmentsInput) {
const capture = (): AttachmentTarget | undefined => { const prompt = input.prompt
const prompt = input.capture() const language = useLanguage()
const editor = input.editor()
if (!editor) return const warn = () => {
return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) } showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
})
} }
const add = async (file: File, toast = true, target = capture()) => { const add = async (file: File, toast = true) => {
if (!target) return false
const mime = await attachmentMime(file) const mime = await attachmentMime(file)
if (!mime) { if (!mime) {
if (toast) input.warn?.() if (toast) warn()
return false return false
} }
const editor = input.editor()
if (!editor) return false
const url = await dataUrl(file, mime) const url = await dataUrl(file, mime)
if (!url) return false if (!url) return false
@@ -76,42 +68,34 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
mime, mime,
dataUrl: url, dataUrl: url,
} }
target.prompt.set([...target.prompt.current(), attachment], target.cursor) const cursor = prompt.cursor() ?? getCursorPosition(editor)
prompt.set([...prompt.current(), attachment], cursor)
return true return true
} }
const addAttachment = (file: File) => add(file) const addAttachment = (file: File) => add(file)
const addAttachments = async (files: File[], toast = true, target = capture()) => { const addAttachments = async (files: File[], toast = true) => {
let found = false let found = false
for (const file of files) { for (const file of files) {
const ok = await add(file, false, target) const ok = await add(file, false)
if (ok) found = true if (ok) found = true
} }
if (!found && files.length > 0 && toast) input.warn?.() if (!found && files.length > 0 && toast) warn()
return found return found
} }
const addClipboardAttachment = async (pending: Promise<File | null>, target = capture()) => {
const file = await pending
if (!file) return false
return add(file, true, target)
}
const removeAttachment = (id: string) => { const removeAttachment = (id: string) => {
const target = input.capture() const current = prompt.current()
const current = target.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id) const next = current.filter((part) => part.type !== "image" || part.id !== id)
target.set(next, target.cursor()) prompt.set(next, prompt.cursor())
} }
const handlePaste = async (event: ClipboardEvent) => { const handlePaste = async (event: ClipboardEvent) => {
const clipboardData = event.clipboardData const clipboardData = event.clipboardData
if (!clipboardData) return if (!clipboardData) return
const target = capture()
if (!target) return
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -123,7 +107,7 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
}) })
if (files.length > 0) { if (files.length > 0) {
await addAttachments(files, true, target) await addAttachments(files)
return return
} }
@@ -131,7 +115,11 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
// Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
if (input.readClipboardImage && !plainText) { if (input.readClipboardImage && !plainText) {
if (await addClipboardAttachment(input.readClipboardImage(), target)) return const file = await input.readClipboardImage()
if (file) {
await addAttachment(file)
return
}
} }
if (!plainText) return if (!plainText) return
@@ -139,9 +127,9 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
const text = normalizePaste(plainText) const text = normalizePaste(plainText)
const put = () => { const put = () => {
if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
input.focusEditor?.() input.focusEditor()
return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false return input.addPart({ type: "text", content: text, start: 0, end: 0 })
} }
if (pasteMode(text) === "manual") { if (pasteMode(text) === "manual") {
@@ -155,28 +143,6 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
put() put()
} }
return {
addAttachment,
addAttachments,
addClipboardAttachment,
removeAttachment,
handlePaste,
}
}
export function createPromptAttachments(input: PromptAttachmentsInput) {
const language = useLanguage()
const attachments = createPromptAttachmentsCore({
...input,
capture: input.prompt.capture,
warn: () => {
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
})
},
})
const handleGlobalDragOver = (event: DragEvent) => { const handleGlobalDragOver = (event: DragEvent) => {
if (input.isDialogActive()) return if (input.isDialogActive()) return
@@ -215,7 +181,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dropped = event.dataTransfer?.files const dropped = event.dataTransfer?.files
if (!dropped) return if (!dropped) return
await attachments.addAttachments(Array.from(dropped)) await addAttachments(Array.from(dropped))
} }
onMount(() => { onMount(() => {
@@ -224,5 +190,10 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
makeEventListener(document, "drop", handleGlobalDrop) makeEventListener(document, "drop", handleGlobalDrop)
}) })
return attachments return {
addAttachment,
addAttachments,
removeAttachment,
handlePaste,
}
} }
@@ -1,8 +1,6 @@
import { Component, For, Match, Show, Switch } from "solid-js" import { Component, For, Match, Show, Switch } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
export type AtOption = export type AtOption =
@@ -32,8 +30,6 @@ type PromptPopoverProps = {
setSlashActive: (id: string) => void setSlashActive: (id: string) => void
onSlashSelect: (item: SlashCommand) => void onSlashSelect: (item: SlashCommand) => void
commandKeybind: (id: string) => string | undefined commandKeybind: (id: string) => string | undefined
commandKeybindParts: (id: string) => string[]
newLayoutDesigns: boolean
t: (key: string) => string t: (key: string) => string
} }
@@ -45,29 +41,15 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
if (props.popover === "slash") props.setSlashPopoverRef(el) if (props.popover === "slash") props.setSlashPopoverRef(el)
}} }}
class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10 class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
overflow-auto no-scrollbar flex flex-col p-2" overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
classList={{ bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
"z-[70] rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": props.newLayoutDesigns,
"rounded-[12px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]":
!props.newLayoutDesigns,
}}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
> >
<Switch> <Switch>
<Match when={props.popover === "at"}> <Match when={props.popover === "at"}>
<Show <Show
when={props.atFlat.length > 0} when={props.atFlat.length > 0}
fallback={ fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
<div
class="px-2 py-1"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{props.t("prompt.popover.emptyResults")}
</div>
}
> >
<For each={props.atFlat.slice(0, 10)}> <For each={props.atFlat.slice(0, 10)}>
{(item) => { {(item) => {
@@ -76,29 +58,13 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
if (item.type === "agent") { if (item.type === "agent") {
return ( return (
<button <button
class="w-full flex items-center gap-x-2 px-2 py-0.5" class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
"rounded-[4px]": props.newLayoutDesigns,
"rounded-md": !props.newLayoutDesigns,
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
}}
onClick={() => props.onAtSelect(item)} onClick={() => props.onAtSelect(item)}
onPointerMove={() => props.setAtActive(key)} onMouseEnter={() => props.setAtActive(key)}
> >
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" /> <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<span <span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
class="whitespace-nowrap"
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-v2-text-text-base": props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
"text-text-strong": !props.newLayoutDesigns,
}}
>
@{item.name}
</span>
</button> </button>
) )
} }
@@ -109,44 +75,16 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
return ( return (
<button <button
class="w-full flex items-center gap-x-2 px-2 py-0.5" class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
"rounded-[4px]": props.newLayoutDesigns,
"rounded-md": !props.newLayoutDesigns,
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
}}
onClick={() => props.onAtSelect(item)} onClick={() => props.onAtSelect(item)}
onPointerMove={() => props.setAtActive(key)} onMouseEnter={() => props.setAtActive(key)}
> >
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" /> <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
<div <div class="flex items-center text-14-regular min-w-0">
class="flex items-center min-w-0" <span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span>
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
}}
>
<span
class="whitespace-nowrap truncate min-w-0"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{directory}
</span>
<Show when={!isDirectory}> <Show when={!isDirectory}>
<span <span class="text-text-strong whitespace-nowrap">{filename}</span>
class="whitespace-nowrap"
classList={{
"text-v2-text-text-base": props.newLayoutDesigns,
"text-text-strong": !props.newLayoutDesigns,
}}
>
{filename}
</span>
</Show> </Show>
</div> </div>
</button> </button>
@@ -158,98 +96,41 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
<Match when={props.popover === "slash"}> <Match when={props.popover === "slash"}>
<Show <Show
when={props.slashFlat.length > 0} when={props.slashFlat.length > 0}
fallback={ fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
<div
class="px-2 py-1"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{props.t("prompt.popover.emptyCommands")}
</div>
}
> >
<For each={props.slashFlat}> <For each={props.slashFlat}>
{(cmd) => { {(cmd) => (
const keybind = () => props.commandKeybind(cmd.id) <button
const keybindParts = () => props.commandKeybindParts(cmd.id) data-slash-id={cmd.id}
return ( classList={{
<button "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
data-slash-id={cmd.id} "bg-surface-raised-base-hover": props.slashActive === cmd.id,
classList={{ }}
"w-full flex items-center justify-between gap-4 px-2 py-1": true, onClick={() => props.onSlashSelect(cmd)}
"rounded-[4px] scroll-my-2": props.newLayoutDesigns, onMouseEnter={() => props.setSlashActive(cmd.id)}
"rounded-md": !props.newLayoutDesigns, >
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.slashActive === cmd.id, <div class="flex items-center gap-2 min-w-0">
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.slashActive === cmd.id, <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
}} <Show when={cmd.description}>
onClick={() => props.onSlashSelect(cmd)} <span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
onPointerMove={() => props.setSlashActive(cmd.id)} </Show>
> </div>
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 shrink-0">
<span <Show when={cmd.type === "custom" && cmd.source !== "command"}>
class="whitespace-nowrap" <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
classList={{ {cmd.source === "skill"
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]": ? props.t("prompt.slash.badge.skill")
props.newLayoutDesigns, : cmd.source === "mcp"
"text-v2-text-text-base": props.newLayoutDesigns, ? props.t("prompt.slash.badge.mcp")
"text-14-regular": !props.newLayoutDesigns, : props.t("prompt.slash.badge.custom")}
"text-text-strong": !props.newLayoutDesigns,
}}
>
/{cmd.trigger}
</span> </span>
<Show when={cmd.description}> </Show>
<span <Show when={props.commandKeybind(cmd.id)}>
class="truncate" <span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
classList={{ </Show>
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]": </div>
props.newLayoutDesigns, </button>
"text-v2-text-text-muted": props.newLayoutDesigns, )}
"text-14-regular": !props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{cmd.description}
</span>
</Show>
</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
<Show
when={props.newLayoutDesigns}
fallback={
<span class="text-11-regular px-1.5 py-0.5 rounded bg-surface-base text-text-subtle">
{cmd.source === "skill"
? props.t("prompt.slash.badge.skill")
: cmd.source === "mcp"
? props.t("prompt.slash.badge.mcp")
: props.t("prompt.slash.badge.custom")}
</span>
}
>
<Tag>
{cmd.source === "skill"
? props.t("prompt.slash.badge.skill")
: cmd.source === "mcp"
? props.t("prompt.slash.badge.mcp")
: props.t("prompt.slash.badge.custom")}
</Tag>
</Show>
</Show>
<Show when={props.newLayoutDesigns ? keybindParts().length > 0 : keybind()}>
<Show
when={props.newLayoutDesigns}
fallback={<span class="text-12-regular text-text-subtle">{keybind()}</span>}
>
<KeybindV2 keys={keybindParts()} variant="neutral" />
</Show>
</Show>
</div>
</button>
)
}}
</For> </For>
</Show> </Show>
</Match> </Match>
@@ -1,31 +0,0 @@
import { type ContextItem, type Prompt, type usePrompt } from "@/context/prompt"
type PromptTarget = ReturnType<ReturnType<typeof usePrompt>["capture"]>
export function createPromptSubmissionState(input: {
target: PromptTarget
prompt: Prompt
context: (ContextItem & { key: string })[]
}) {
let target = input.target
let cleared: Prompt | undefined
return {
prompt: input.prompt,
context: input.context,
target: () => target,
clear() {
target.reset()
cleared = target.current()
},
retarget(next: PromptTarget) {
input.context.forEach(next.context.add)
target = next
},
current: (value: PromptTarget) => target === value,
restore() {
if (cleared !== undefined && target.current() !== cleared) return
return { target, prompt: input.prompt, context: input.context }
},
}
}
@@ -20,10 +20,8 @@ const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const promoted: Array<{ directory: string; sessionID: string }> = [] const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: string[] = [] const sentShell: string[] = []
const syncedDirectories: string[] = [] const syncedDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
let params: { id?: string } = {} let params: { id?: string } = {}
let search: { draftId?: string } = {}
let selected = "/repo/worktree-a" let selected = "/repo/worktree-a"
let variant: string | undefined let variant: string | undefined
@@ -43,7 +41,6 @@ const prompt = {
replaceComments: () => undefined, replaceComments: () => undefined,
items: () => [], items: () => [],
}, },
capture: () => prompt,
} }
const clientFor = (directory: string) => { const clientFor = (directory: string) => {
@@ -81,7 +78,7 @@ beforeAll(async () => {
useNavigate: () => () => undefined, useNavigate: () => () => undefined,
useParams: () => params, useParams: () => params,
useLocation: () => ({}), useLocation: () => ({}),
useSearchParams: () => [search, () => undefined], useSearchParams: () => [{}, () => undefined],
})) }))
mock.module("@opencode-ai/sdk/v2/client", () => ({ mock.module("@opencode-ai/sdk/v2/client", () => ({
@@ -131,10 +128,7 @@ beforeAll(async () => {
mock.module("@/context/tabs", () => ({ mock.module("@/context/tabs", () => ({
useTabs: () => ({ useTabs: () => ({
draft: () => ({ server: "project-server" }), promoteDraft: () => undefined,
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
promotedDrafts.push({ draftID, ...session })
},
}), }),
})) }))
@@ -239,9 +233,7 @@ beforeEach(() => {
optimistic.length = 0 optimistic.length = 0
optimisticSeeded.length = 0 optimisticSeeded.length = 0
promoted.length = 0 promoted.length = 0
promotedDrafts.length = 0
params = {} params = {}
search = {}
sentShell.length = 0 sentShell.length = 0
syncedDirectories.length = 0 syncedDirectories.length = 0
selected = "/repo/worktree-a" selected = "/repo/worktree-a"
@@ -316,33 +308,6 @@ describe("prompt submit worktree selection", () => {
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }]) expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
}) })
test("promotes drafts using the selected project's server", async () => {
search = { draftId: "draft-1" }
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
})
test("includes the selected variant on optimistic prompts", async () => { test("includes the selected variant on optimistic prompts", async () => {
params = { id: "session-1" } params = { id: "session-1" }
variant = "high" variant = "high"
@@ -4,6 +4,8 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { batch, type Accessor } from "solid-js" import { batch, type Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync" import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -19,7 +21,6 @@ import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope" import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
type PendingPrompt = { type PendingPrompt = {
abort: AbortController abort: AbortController
@@ -193,6 +194,15 @@ type PromptSubmitInput = {
onSubmit?: () => void onSubmit?: () => void
} }
type CommentItem = {
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export function createPromptSubmit(input: PromptSubmitInput) { export function createPromptSubmit(input: PromptSubmitInput) {
const navigate = useNavigate() const navigate = useNavigate()
const sdk = useSDK() const sdk = useSDK()
@@ -205,6 +215,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const language = useLanguage() const language = useLanguage()
const params = useParams() const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const server = useServer()
const tabs = useTabs() const tabs = useTabs()
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID) const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
@@ -240,12 +251,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch(() => {}) .catch(() => {})
} }
const restoreCommentItems = ( const restoreCommentItems = (items: CommentItem[]) => {
target: ReturnType<ReturnType<typeof usePrompt>["capture"]>,
items: (ContextItem & { key: string })[],
) => {
for (const item of items) { for (const item of items) {
target.context.add({ prompt.context.add({
type: "file", type: "file",
path: item.path, path: item.path,
selection: item.selection, selection: item.selection,
@@ -257,9 +265,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
} }
const clearContext = (target: ReturnType<ReturnType<typeof usePrompt>["capture"]>) => { const removeCommentItems = (items: { key: string }[]) => {
for (const item of target.context.items()) { for (const item of items) {
target.context.remove(item.key) prompt.context.remove(item.key)
}
}
const clearContext = () => {
for (const item of prompt.context.items()) {
prompt.context.remove(item.key)
} }
} }
@@ -281,14 +295,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const handleSubmit = async (event: Event) => { const handleSubmit = async (event: Event) => {
event.preventDefault() event.preventDefault()
const target = prompt.capture() const currentPrompt = prompt.current()
const submission = createPromptSubmissionState({
target,
prompt: target.current(),
context: target.context.items().slice(),
})
const currentPrompt = submission.prompt
const context = submission.context
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
const images = input.imageAttachments().slice() const images = input.imageAttachments().slice()
const mode = input.mode() const mode = input.mode()
@@ -378,9 +385,8 @@ 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: tabs.draft(draftID).server, sessionId: session.id }) if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
} }
} }
if (!session) { if (!session) {
@@ -396,6 +402,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
providerID: currentModel.provider.id, providerID: currentModel.provider.id,
} }
const agent = currentAgent.name const agent = currentAgent.name
const context = prompt.context.items().slice()
const draft: FollowupDraft = { const draft: FollowupDraft = {
sessionID: session.id, sessionID: session.id,
sessionDirectory, sessionDirectory,
@@ -407,16 +414,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
const clearInput = () => { const clearInput = () => {
submission.clear() prompt.reset()
input.setMode("normal") input.setMode("normal")
input.setPopover(null) input.setPopover(null)
} }
const restoreInput = () => { const restoreInput = () => {
const restored = submission.restore() prompt.set(currentPrompt, input.promptLength(currentPrompt))
if (!restored) return false
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
if (!submission.current(prompt.capture())) return true
input.setMode(mode) input.setMode(mode)
input.setPopover(null) input.setPopover(null)
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -426,12 +430,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
setCursorPosition(editor, input.promptLength(currentPrompt)) setCursorPosition(editor, input.promptLength(currentPrompt))
input.queueScroll() input.queueScroll()
}) })
return true
} }
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
input.onQueue?.(draft) input.onQueue?.(draft)
clearContext(submission.target()) clearContext()
clearInput() clearInput()
return return
} }
@@ -501,7 +504,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}) })
} }
for (const item of commentItems) submission.target().context.remove(item.key) removeCommentItems(commentItems)
clearInput() clearInput()
const waitForWorktree = async () => { const waitForWorktree = async () => {
@@ -518,7 +521,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
sync().set("session_status", session.id, { type: "idle" }) sync().set("session_status", session.id, { type: "idle" })
} }
removeOptimisticMessage() removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems) restoreCommentItems(commentItems)
restoreInput()
} }
pending.set(pendingKey(session.id), { abort: controller, cleanup }) pending.set(pendingKey(session.id), { abort: controller, cleanup })
@@ -580,7 +584,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
description: errorMessage(err), description: errorMessage(err),
}) })
removeOptimisticMessage() removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems) restoreCommentItems(commentItems)
restoreInput()
}) })
} }
@@ -1,43 +0,0 @@
import { createComputed, on, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { PromptHistoryEntry } from "./history"
export type PromptInputTransientState = {
popover: "at" | "slash" | null
historyIndex: number
savedPrompt: PromptHistoryEntry | null
placeholder: number
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
variantOpen: boolean
}
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
setStore({
popover: null,
historyIndex: -1,
savedPrompt: null,
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
}
export function createPromptInputTransientState(identity: Accessor<unknown>, placeholder: number) {
const [store, setStore] = createStore<PromptInputTransientState>({
popover: null,
historyIndex: -1,
savedPrompt: null,
placeholder,
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
return [store, setStore] as const
}
@@ -1,551 +0,0 @@
import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { getProjectAvatarVariant } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { pathKey } from "@/utils/path-key"
export type PromptProject = {
name?: string
id?: string
worktree: string
sandboxes?: string[]
icon?: { color?: string; url?: string; override?: string }
server?: { key: string; name: string }
}
export type PromptProjectControls = {
available: PromptProject[]
directory: string
server?: string
select: (worktree: string, server?: string) => void
add: (title: string, server?: string) => void
}
const actionPrefix = "action:"
const projectPrefix = "project:"
function projectKey(project: PromptProject) {
return `${projectPrefix}${encodeURIComponent(project.server?.key ?? "")}:${encodeURIComponent(project.worktree)}`
}
function actionKey(server?: string) {
return `${actionPrefix}${encodeURIComponent(server ?? "")}`
}
export function createPromptProjectController(input: {
controls: Accessor<PromptProjectControls>
onDone: () => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined
const selected = () => {
const key = pathKey(input.controls().directory)
return input
.controls()
.available.find(
(project) =>
(!project.server || project.server.key === input.controls().server) &&
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
)
}
const projects = () => {
const search = store.search.trim().toLowerCase()
if (!search) return input.controls().available
return input.controls().available.filter((project) => displayName(project).toLowerCase().includes(search))
}
const servers = () =>
input
.controls()
.available.map((project) => project.server)
.filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index)
const keys = () => {
if (servers().length <= 1) {
return [...projects().map(projectKey), actionKey(servers()[0]?.key)]
}
return [
...servers().flatMap((server) =>
projects()
.filter((project) => project.server?.key === server!.key)
.map(projectKey),
),
actionKey(),
]
}
const initialActive = () => {
const selectedKey = selected() ? projectKey(selected()!) : undefined
const options = keys()
if (selectedKey && options.includes(selectedKey)) return selectedKey
return options[0] ?? ""
}
const close = () => {
setStore({ open: false, search: "", active: "" })
input.onDone()
}
const select = (project: PromptProject) => {
if (
pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") ||
project.server?.key !== selected()?.server?.key
) {
input.controls().select(project.worktree, project.server?.key)
}
close()
}
const add = (server?: string) => {
setStore({ open: false, search: "", active: "" })
input.controls().add(language.t("command.project.open"), server)
}
return {
selected,
projects,
servers,
projectKey,
actionKey,
open: () => store.open,
search: () => store.search,
active: () => store.active,
labels: {
add: () => language.t("session.new.project.add"),
clear: () => language.t("common.clear"),
new: () => language.t("session.new.project.new"),
search: () => language.t("session.new.project.search"),
},
add,
select,
setOpen(open: boolean) {
if (open) {
setStore({ open: true, active: initialActive() })
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
return
}
setStore({ open: false, search: "", active: "" })
},
setSearch(value: string) {
const search = value.trim().toLowerCase()
const first = input
.controls()
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
setStore({
search: value,
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
})
},
clearSearch() {
setStore({ search: "", active: initialActive() })
setTimeout(() => searchRef?.focus())
},
setActive(key: string) {
setStore("active", key)
},
moveActive(delta: number) {
const options = keys()
if (options.length === 0) return
const index = options.indexOf(store.active)
const start = index === -1 ? 0 : index
setStore("active", options[(start + delta + options.length) % options.length])
},
activeProject() {
return store.active.startsWith(projectPrefix)
? projects().find((project) => projectKey(project) === store.active)
: undefined
},
activeServer() {
return store.active.startsWith(actionPrefix)
? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined
: undefined
},
activeAction() {
return store.active.startsWith(actionPrefix)
},
setSearchRef(el: HTMLInputElement) {
searchRef = el
},
focusSearch() {
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
},
}
}
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
export function PromptProjectSelector(props: {
controller: PromptProjectController
placement?: "bottom" | "bottom-start"
}) {
let contentRef: HTMLDivElement | undefined
let restoreTrigger = true
const activeItem = () =>
props.controller.active()
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
: undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const selectProject = (project: PromptProject) => {
restoreTrigger = false
props.controller.setOpen(false)
afterClose(() => props.controller.select(project))
}
const selectAction = (server?: string) => {
restoreTrigger = false
props.controller.setOpen(false)
afterClose(() => props.controller.add(server))
}
const selectActive = () => {
const project = props.controller.activeProject()
if (project) {
selectProject(project)
return
}
if (props.controller.activeAction() && props.controller.servers().length > 1) {
const item = activeItem()
item?.focus()
item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }))
return
}
selectAction(props.controller.activeServer())
}
const moveActive = (delta: number) => {
props.controller.moveActive(delta)
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
}
const focusPreviousControl = () => {
const target = Array.from(
document.querySelectorAll<HTMLElement>(
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
)
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
.findLast((element) => element.offsetParent !== null)
restoreTrigger = false
target?.focus()
queueMicrotask(() => {
if (props.controller.open()) props.controller.setOpen(false)
})
}
const selectedValue = () => {
const project = props.controller.selected()
return project ? props.controller.projectKey(project) : undefined
}
return (
<DropdownMenu
open={props.controller.open()}
placement={props.placement ?? "bottom"}
gutter={4}
modal={false}
onOpenChange={(open) => props.controller.setOpen(open)}
>
<DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
<DropdownMenu.Portal>
<DropdownMenu.Content
ref={contentRef}
id="prompt-project-menu"
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
onOpenAutoFocus={(event) => event.preventDefault()}
onPointerDownOutside={() => (restoreTrigger = false)}
onFocusOutside={() => (restoreTrigger = false)}
onCloseAutoFocus={(event) => {
if (!restoreTrigger) event.preventDefault()
}}
>
<div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(el) => props.controller.setSearchRef(el)}
value={props.controller.search()}
placeholder={props.controller.labels.search()}
aria-autocomplete="list"
aria-controls="prompt-project-menu"
aria-activedescendant={props.controller.active() || undefined}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => props.controller.setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Tab") {
event.preventDefault()
event.stopPropagation()
if (event.shiftKey) {
focusPreviousControl()
return
}
activeItem()?.focus()
return
}
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
props.controller.setOpen(false)
return
}
if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
moveActive(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
moveActive(-1)
return
}
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
selectActive()
}
}}
/>
<Show when={props.controller.search().trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onPointerDown={(event) => event.preventDefault()}
onClick={() => props.controller.clearSearch()}
aria-label={props.controller.labels.clear()}
>
<Icon name="close-small" size="small" />
</button>
</Show>
</div>
<Show
when={props.controller.servers().length > 1}
fallback={
<DropdownMenu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects()}>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
}
>
<For
each={props.controller
.servers()
.filter((server) =>
props.controller.projects().some((project) => project.server?.key === server!.key),
)}
>
{(server) => (
<div>
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
{server!.name}
</div>
<DropdownMenu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
</div>
)}
</For>
</Show>
</div>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<Show
when={props.controller.servers().length > 1}
fallback={
<ProjectAction
server={props.controller.servers()[0]?.key}
controller={props.controller}
onSelect={selectAction}
/>
}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
id={props.controller.actionKey()}
data-option-key={props.controller.actionKey()}
class={projectActionClass}
classList={{
"!bg-v2-overlay-simple-overlay-hover": props.controller.active() === props.controller.actionKey(),
}}
onMouseEnter={() => props.controller.setActive(props.controller.actionKey())}
>
<Icon name="plus" size="small" />
<span data-slot="dropdown-menu-item-label" class="min-w-0 flex-1 truncate leading-5">
{props.controller.labels.add()}
</span>
<Icon name="chevron-right" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<For each={props.controller.servers()}>
{(server) => <ServerAction server={server!} onSelect={selectAction} />}
</For>
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
</Show>
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)
}
export function PromptProjectAddButton(props: { controller: PromptProjectController }) {
return (
<button
data-action="prompt-project"
type="button"
class="flex h-7 min-w-0 max-w-[160px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => props.controller.add()}
>
<Icon name="folder-add-left" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate leading-5">{props.controller.labels.new()}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</button>
)
}
function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) {
const [local, rest] = splitProps(props, ["controller", "class", "classList", "onClick", "onKeyDown"])
const project = () => local.controller.selected()
return (
<button
{...rest}
data-action="prompt-project"
type="button"
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
classList={{
...local.classList,
"hover:bg-v2-overlay-simple-overlay-hover": !local.controller.open(),
"bg-v2-overlay-simple-overlay-pressed": local.controller.open(),
"text-v2-text-text-muted": local.controller.open(),
}}
onClick={local.onClick ?? (() => local.controller.setOpen(true))}
onKeyDown={(event) => {
if (!local.controller.open() && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault()
event.stopPropagation()
return
}
if (typeof local.onKeyDown === "function") local.onKeyDown(event)
}}
>
<Show
when={project()}
fallback={<Icon name="folder-add-left" size="small" class="shrink-0 text-v2-icon-icon-muted" />}
>
{(item) => (
<ProjectAvatar
fallback={displayName(item())}
src={getProjectAvatarSource(item().id, item().icon)}
variant={getProjectAvatarVariant(item().icon?.color)}
/>
)}
</Show>
<span class="min-w-0 truncate leading-5">
{project() ? displayName(project()!) : local.controller.labels.new()}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</button>
)
}
function ProjectItem(props: {
project: PromptProject
controller: PromptProjectController
onSelect: (project: PromptProject) => void
}) {
const key = () => props.controller.projectKey(props.project)
return (
<DropdownMenu.RadioItem
id={key()}
value={key()}
data-option-key={key()}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
style={{
"font-family": "var(--v2-font-family-sans)",
"font-size": "13px",
"font-weight": 440,
"line-height": "20px",
"letter-spacing": "-0.04px",
color: "var(--v2-text-text-base)",
padding: "0 12px",
}}
closeOnSelect
onMouseEnter={() => {
props.controller.setActive(key())
props.controller.focusSearch()
}}
onSelect={() => props.onSelect(props.project)}
>
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">{displayName(props.project)}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator style={{ width: "14px", height: "14px", right: "12px" }}>
<IconV2 name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)
}
const projectActionClass =
"h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
function ProjectAction(props: {
server?: string
controller: PromptProjectController
onSelect: (server?: string) => void
}) {
const key = () => props.controller.actionKey(props.server)
return (
<DropdownMenu.Item
id={key()}
data-option-key={key()}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
style={{
"font-family": "var(--v2-font-family-sans)",
"font-size": "13px",
"font-weight": 440,
"line-height": "20px",
"letter-spacing": "-0.04px",
color: "var(--v2-text-text-base)",
padding: "0 12px",
}}
onMouseEnter={() => {
props.controller.setActive(key())
props.controller.focusSearch()
}}
onSelect={() => props.onSelect(props.server)}
>
<Icon name="plus" size="small" />
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">
{props.controller.labels.add()}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
)
}
function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) {
return (
<DropdownMenu.Item class={projectActionClass} onSelect={() => props.onSelect(props.server.key)}>
<DropdownMenu.ItemLabel class="min-w-0 flex-1 truncate leading-5">{props.server.name}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
)
}
@@ -1,102 +0,0 @@
import { For, Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language"
export function PromptWorkspaceSelector(props: {
value: string
projectRoot: string
workspaces: string[]
branch?: string
onChange: (value: string) => void
onDone: () => void
}) {
const language = useLanguage()
let pending: string | undefined
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
const icon = () => {
if (selected() === "main") return "monitor"
if (selected() === "create") return "workspace-new"
return "workspace"
}
const select = (value: string) => {
pending = value
}
const onOpenChange = (open: boolean) => {
if (open) return
const value = pending
pending = undefined
if (value) props.onChange(value)
props.onDone()
}
const label = () => {
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
if (props.value === "create") return language.t("workspace.new")
return getFilename(props.value)
}
return (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{label()}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="w-[180px]">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<MenuV2.Item onSelect={() => select("main")}>
<IconV2 name="monitor" />
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
<Show when={selected() === "main"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
<MenuV2.Item onSelect={() => select("create")}>
<IconV2 name="workspace-new" />
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
</MenuV2.Group>
<Show when={props.workspaces.length > 0}>
<MenuV2.Separator />
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<IconV2 name="workspace" />
{language.t("session.new.workspace.existing")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-w-[200px]">
<For each={props.workspaces}>
{(workspace) => (
<MenuV2.Item onSelect={() => select(workspace)}>
<IconV2 name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
)}
</For>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{props.branch || "main"}</span>
</div>
</>
)
}
@@ -19,7 +19,7 @@ export const ServerRowMenu: Component<{
const isDefault = () => props.controller.defaultKey() === key const isDefault = () => props.controller.defaultKey() === key
return ( return (
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}> <MenuV2 gutter={4} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
<MenuV2.Trigger <MenuV2.Trigger
as={IconButtonV2} as={IconButtonV2}
variant="ghost-muted" variant="ghost-muted"
@@ -117,7 +117,7 @@ export function ServerHealthIndicator(props: { health?: ServerHealth }) {
return ( return (
<div <div
classList={{ classList={{
"size-1.5 rounded-full shrink-0 my-[3.5px]": true, "size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": props.health?.healthy === true, "bg-icon-success-base": props.health?.healthy === true,
"bg-icon-critical-base": props.health?.healthy === false, "bg-icon-critical-base": props.health?.healthy === false,
"bg-border-weak-base": props.health === undefined, "bg-border-weak-base": props.health === undefined,
@@ -1,23 +1,19 @@
import { Match, Show, Switch, createMemo } from "solid-js" import { Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip" import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk" import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
interface SessionContextUsageProps { interface SessionContextUsageProps {
variant?: "button" | "indicator" variant?: "button" | "indicator"
buttonAppearance?: "default" | "v2"
placement?: TooltipProps["placement"] placement?: TooltipProps["placement"]
} }
@@ -37,19 +33,16 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const file = useFile() const file = useFile()
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const sdk = useSDK() const providers = useProviders()
const providers = useProviders(() => sdk().directory)
const { params, tabs, view } = useSessionLayout() const { params, tabs, view } = useSessionLayout()
const variant = createMemo(() => props.variant ?? "button") const variant = createMemo(() => props.variant ?? "button")
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
const tabState = createSessionTabs({ const tabState = createSessionTabs({
tabs, tabs,
pathFromTab: file.pathFromTab, pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}) })
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
const usd = createMemo( const usd = createMemo(
() => () =>
@@ -59,10 +52,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}), }),
) )
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens) const context = createMemo(() => metrics().context)
const cost = createMemo(() => { const cost = createMemo(() => {
return usd().format(info()?.cost ?? 0) return usd().format(metrics().totalCost)
}) })
const openContext = () => { const openContext = () => {
@@ -84,30 +77,21 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} /> <ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} />
</div> </div>
) )
const circleV2 = () => (
<div class="flex items-center justify-center">
<ProgressCircleV2 percentage={context()?.usage ?? 0} />
</div>
)
const tooltipValue = () => ( const tooltipValue = () => (
<div> <div>
<Show when={tokens()}>
{(value) => (
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">
{getSessionTokenTotal(value())?.toLocaleString(language.intl())}
</span>
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
</div>
)}
</Show>
<Show when={context()}> <Show when={context()}>
{(ctx) => ( {(ctx) => (
<div class="flex items-center gap-2"> <>
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span> <div class="flex items-center gap-2">
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span> <span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span>
</div> <span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
</div>
</>
)} )}
</Show> </Show>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -122,16 +106,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}> <Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
<Switch> <Switch>
<Match when={variant() === "indicator"}>{circle()}</Match> <Match when={variant() === "indicator"}>{circle()}</Match>
<Match when={buttonAppearance() === "v2"}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
icon={circleV2()}
onClick={openContext}
aria-label={language.t("context.usage.view")}
/>
</Match>
<Match when={true}> <Match when={true}>
<Button <Button
type="button" type="button"
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client" import type { Message } from "@opencode-ai/sdk/v2/client"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics" import { getSessionContextMetrics } from "./session-context-metrics"
const assistant = ( const assistant = (
id: string, id: string,
@@ -37,8 +37,8 @@ const user = (id: string) => {
} as unknown as Message } as unknown as Message
} }
describe("getSessionContext", () => { describe("getSessionContextMetrics", () => {
test("computes usage from latest assistant with tokens", () => { test("computes totals and usage from latest assistant with tokens", () => {
const messages = [ const messages = [
user("u1"), user("u1"),
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5), assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
@@ -57,52 +57,45 @@ describe("getSessionContext", () => {
}, },
] ]
const ctx = getSessionContext(messages, providers) const metrics = getSessionContextMetrics(messages, providers)
expect(ctx?.message.id).toBe("a2") expect(metrics.totalCost).toBe(1.75)
expect(ctx?.usage).toBe(50) expect(metrics.context?.message.id).toBe("a2")
expect(ctx?.providerLabel).toBe("OpenAI") expect(metrics.context?.total).toBe(500)
expect(ctx?.modelLabel).toBe("GPT-4.1") expect(metrics.context?.usage).toBe(50)
expect(metrics.context?.providerLabel).toBe("OpenAI")
expect(metrics.context?.modelLabel).toBe("GPT-4.1")
}) })
test("preserves fallback labels and null usage when model metadata is missing", () => { test("preserves fallback labels and null usage when model metadata is missing", () => {
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")] const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
const providers = [{ id: "p-1", models: {} }] const providers = [{ id: "p-1", models: {} }]
const ctx = getSessionContext(messages, providers) const metrics = getSessionContextMetrics(messages, providers)
expect(ctx?.providerLabel).toBe("p-1") expect(metrics.context?.providerLabel).toBe("p-1")
expect(ctx?.modelLabel).toBe("m-1") expect(metrics.context?.modelLabel).toBe("m-1")
expect(ctx?.limit).toBeUndefined() expect(metrics.context?.limit).toBeUndefined()
expect(ctx?.usage).toBeNull() expect(metrics.context?.usage).toBeNull()
}) })
test("recomputes when message array is mutated in place", () => { test("recomputes when message array is mutated in place", () => {
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)] const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
const providers = [{ id: "openai", models: {} }] const providers = [{ id: "openai", models: {} }]
const one = getSessionContext(messages, providers) const one = getSessionContextMetrics(messages, providers)
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75)) messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
const two = getSessionContext(messages, providers) const two = getSessionContextMetrics(messages, providers)
expect(one?.message.id).toBe("a1") expect(one.context?.message.id).toBe("a1")
expect(two?.message.id).toBe("a2") expect(two.context?.message.id).toBe("a2")
expect(two.totalCost).toBe(1)
}) })
test("returns undefined when inputs are undefined", () => { test("returns empty metrics when inputs are undefined", () => {
const ctx = getSessionContext(undefined, undefined) const metrics = getSessionContextMetrics(undefined, undefined)
expect(ctx).toBeUndefined() expect(metrics.totalCost).toBe(0)
}) expect(metrics.context).toBeUndefined()
test("computes stored session token totals", () => {
expect(
getSessionTokenTotal({
input: 10,
output: 20,
reasoning: 30,
cache: { read: 40, write: 50 },
}),
).toBe(150)
}) })
}) })
@@ -1,4 +1,4 @@
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client" import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
type Provider = { type Provider = {
id: string id: string
@@ -21,9 +21,19 @@ type Context = {
modelLabel: string modelLabel: string
limit: number | undefined limit: number | undefined
input: number input: number
output: number
reasoning: number
cacheRead: number
cacheWrite: number
total: number
usage: number | null usage: number | null
} }
type Metrics = {
totalCost: number
context: Context | undefined
}
const tokenTotal = (msg: AssistantMessage) => { const tokenTotal = (msg: AssistantMessage) => {
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
} }
@@ -37,9 +47,10 @@ const lastAssistantWithTokens = (messages: Message[]) => {
} }
} }
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => { const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => {
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
const message = lastAssistantWithTokens(messages) const message = lastAssistantWithTokens(messages)
if (!message) return undefined if (!message) return { totalCost, context: undefined }
const provider = providers.find((item) => item.id === message.providerID) const provider = providers.find((item) => item.id === message.providerID)
const model = provider?.models[message.modelID] const model = provider?.models[message.modelID]
@@ -47,22 +58,25 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
const total = tokenTotal(message) const total = tokenTotal(message)
return { return {
message, totalCost,
provider, context: {
model, message,
providerLabel: provider?.name ?? message.providerID, provider,
modelLabel: model?.name ?? message.modelID, model,
limit, providerLabel: provider?.name ?? message.providerID,
input: message.tokens.input, modelLabel: model?.name ?? message.modelID,
usage: limit ? Math.round((total / limit) * 100) : null, limit,
input: message.tokens.input,
output: message.tokens.output,
reasoning: message.tokens.reasoning,
cacheRead: message.tokens.cache.read,
cacheWrite: message.tokens.cache.write,
total,
usage: limit ? Math.round((total / limit) * 100) : null,
},
} }
} }
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) { export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers) return build(messages, providers)
} }
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
if (!tokens) return undefined
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
@@ -13,9 +13,8 @@ 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"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics" import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format" import { createSessionContextFormatter } from "./session-context-format"
@@ -94,8 +93,7 @@ const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() { export function SessionContextTab() {
const sync = useSync() const sync = useSync()
const language = useLanguage() const language = useLanguage()
const sdk = useSDK() const providers = useProviders()
const providers = useProviders(() => sdk().directory)
const { params, view } = useSessionLayout() const { params, view } = useSessionLayout()
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
@@ -134,12 +132,12 @@ export function SessionContextTab() {
}), }),
) )
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens) const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.intl())) const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
const cost = createMemo(() => { const cost = createMemo(() => {
return usd().format(info()?.cost ?? 0) return usd().format(metrics().totalCost)
}) })
const counts = createMemo(() => { const counts = createMemo(() => {
@@ -204,14 +202,14 @@ export function SessionContextTab() {
{ label: "context.stats.provider", value: providerLabel }, { label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel }, { label: "context.stats.model", value: modelLabel },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) }, { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) }, { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) }, { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) }, { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) },
{ {
label: "context.stats.cacheTokens", label: "context.stats.cacheTokens",
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`, value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
}, },
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
@@ -28,9 +28,6 @@ import { Persist, persisted } from "@/utils/persist"
import { StatusPopover, StatusPopoverV2 } from "../status-popover" import { StatusPopover, StatusPopoverV2 } from "../status-popover"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
const OPEN_APPS = [ const OPEN_APPS = [
"vscode", "vscode",
@@ -240,7 +237,7 @@ export function SessionHeader() {
statusVisible: status(), statusVisible: status(),
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: reviewTooltipKeybind(command), reviewKeybind: command.keybind("review.toggle"),
reviewVisible: isDesktop(), reviewVisible: isDesktop(),
reviewOpened: view().reviewPanel.opened(), reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(), onReviewToggle: () => view().reviewPanel.toggle(),
@@ -523,15 +520,13 @@ type SessionHeaderV2ActionsState = {
statusVisible: boolean statusVisible: boolean
statusLabel: string statusLabel: string
reviewLabel: string reviewLabel: string
reviewKeybind: string[] reviewKeybind: string
reviewVisible: boolean reviewVisible: boolean
reviewOpened: boolean reviewOpened: boolean
onReviewToggle: () => void onReviewToggle: () => void
} }
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) { function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
const language = useLanguage()
return ( return (
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Show when={props.state.statusVisible}> <Show when={props.state.statusVisible}>
@@ -540,17 +535,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
</Tooltip> </Tooltip>
</Show> </Show>
<Show when={props.state.reviewVisible}> <Show when={props.state.reviewVisible}>
<TooltipV2 <TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
placement="bottom"
value={
<>
{props.state.reviewLabel}
<Show when={props.state.reviewKeybind.length > 0}>
<KeybindV2 keys={props.state.reviewKeybind} variant="neutral" />
</Show>
</>
}
>
<IconButtonV2 <IconButtonV2
type="button" type="button"
variant="ghost-muted" variant="ghost-muted"
@@ -563,7 +548,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
aria-controls="review-panel" aria-controls="review-panel"
icon={<IconV2 name="sidebar-right" />} icon={<IconV2 name="sidebar-right" />}
/> />
</TooltipV2> </TooltipKeybind>
</Show> </Show>
</div> </div>
) )
@@ -1,43 +0,0 @@
import { useParams } from "@solidjs/router"
import { onCleanup } from "solid-js"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useDialog } from "@opencode-ai/ui/context/dialog"
export function useSettingsDialog() {
const dialog = useDialog()
const params = useParams<{ id?: string }>()
let run = 0
let dead = false
onCleanup(() => {
dead = true
})
return () => {
const current = ++run
const sessionID = params.id
void import("@/components/settings-v2").then((module) => {
if (dead || run !== current) return
void dialog.show(() => <module.DialogSettings sessionID={sessionID} />)
})
}
}
export function useSettingsCommand() {
const command = useCommand()
const language = useLanguage()
const show = useSettingsDialog()
command.register("settings", () => [
{
id: "settings.open",
title: language.t("command.settings.open"),
category: language.t("command.category.settings"),
keybind: "mod+comma",
onSelect: show,
},
])
return show
}
@@ -1,6 +1,5 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js" import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
@@ -53,12 +52,8 @@ export const DialogServerV2: Component<{
} }
return ( return (
<Dialog fit class="settings-v2-server-dialog"> <Dialog title={title()} fit class="settings-v2-server-dialog">
<DialogHeader hideClose={true}> <div class="flex w-full min-w-0 flex-1 flex-col px-4">
<DialogTitle>{title()}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
<div class="flex w-full min-w-0 flex-col gap-6"> <div class="flex w-full min-w-0 flex-col gap-6">
<div class="flex w-full min-w-0 flex-col gap-2"> <div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label> <label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
@@ -120,7 +115,7 @@ export const DialogServerV2: Component<{
</div> </div>
</div> </div>
</div> </div>
</DialogBody> </div>
<DialogFooter> <DialogFooter>
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}> <ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
{language.t("common.cancel")} {language.t("common.cancel")}
@@ -11,9 +11,7 @@ import { SettingsModelsV2 } from "./models"
import "./settings-v2.css" import "./settings-v2.css"
import { SettingsServersV2 } from "./servers" import { SettingsServersV2 } from "./servers"
export const DialogSettings: Component<{ export const DialogSettings: Component = () => {
sessionID?: string
}> = (props) => {
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
@@ -64,7 +62,7 @@ export const DialogSettings: Component<{
</div> </div>
</TabsV2.List> </TabsV2.List>
<TabsV2.Content value="general" class="settings-v2-panel"> <TabsV2.Content value="general" class="settings-v2-panel">
<SettingsGeneralV2 sessionID={props.sessionID} /> <SettingsGeneralV2 />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="shortcuts" class="settings-v2-panel"> <TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds v2 /> <SettingsKeybinds v2 />
@@ -6,6 +6,7 @@ import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
@@ -24,6 +25,7 @@ import {
terminalInput, terminalInput,
useSettings, useSettings,
} from "@/context/settings" } from "@/context/settings"
import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "../link" import { Link } from "../link"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
@@ -80,46 +82,50 @@ const playDemoSound = (id: string | undefined) => {
}, 100) }, 100)
} }
export const SettingsGeneralV2: Component<{ export const SettingsGeneralV2: Component = () => {
sessionID?: string
}> = (props) => {
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const permission = usePermission() const permission = usePermission()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
const params = useParams()
const settings = useSettings() const settings = useSettings()
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const mobile = createMediaQuery("(max-width: 767px)") const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction() const updater = useUpdaterAction()
const dir = createMemo(() => { const dir = createMemo(() => decode64(params.dir))
if (!props.sessionID) return undefined
return serverSync().session.lineage.peek(props.sessionID)?.session.directory
})
const accepting = createMemo(() => { const accepting = createMemo(() => {
const value = dir() const value = dir()
if (!value || !props.sessionID) return false if (!value) return false
return permission.isAutoAccepting(props.sessionID, value) if (!params.id) return permission.isAutoAcceptingDirectory(value)
return permission.isAutoAccepting(params.id, value)
}) })
const toggleAccept = (checked: boolean) => { const toggleAccept = (checked: boolean) => {
const value = dir() const value = dir()
if (!value || !props.sessionID) return if (!value) return
if (checked) { if (!params.id) {
permission.enableAutoAccept(props.sessionID, value) if (permission.isAutoAcceptingDirectory(value) === checked) return
permission.toggleAutoAcceptDirectory(value)
return return
} }
permission.disableAutoAccept(props.sessionID, value) if (checked) {
permission.enableAutoAccept(params.id, value)
return
}
permission.disableAutoAccept(params.id, value)
} }
const desktop = createMemo(() => platform.platform === "desktop") const desktop = createMemo(() => platform.platform === "desktop")
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const [shells] = createResource( const [shells] = createResource(
() => () =>
serverSdk() serverSdk()
@@ -330,7 +336,7 @@ export const SettingsGeneralV2: Component<{
</div> </div>
</SettingsRowV2> </SettingsRowV2>
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}> <Show when={mobile()}>
<SettingsRowV2 <SettingsRowV2
title={language.t("settings.general.row.mobileTitlebarBottom.title")} title={language.t("settings.general.row.mobileTitlebarBottom.title")}
description={language.t("settings.general.row.mobileTitlebarBottom.description")} description={language.t("settings.general.row.mobileTitlebarBottom.description")}
@@ -633,7 +633,7 @@
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] { [data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] {
align-items: center; align-items: center;
padding: 24px 24px 16px; padding: 24px 24px 0;
} }
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] { [data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
@@ -5,8 +5,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover" import { Popover } from "@opencode-ai/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js" import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection, useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
@@ -82,11 +81,11 @@ export function StatusPopoverV2(props: { scope?: "server" }) {
function DirectoryStatusPopover() { function DirectoryStatusPopover() {
const language = useLanguage() const language = useLanguage()
const server = useServerSDK() const server = useServer()
const global = useGlobal() const global = useGlobal()
const sync = useSync() const sync = useSync()
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready) const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
const mcpIssue = createMemo(() => { const mcpIssue = createMemo(() => {
const mcp = Object.values(sync().data.mcp ?? {}) const mcp = Object.values(sync().data.mcp ?? {})
+4 -6
View File
@@ -10,7 +10,7 @@ import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk" import { useServer } from "@/context/server"
import { terminalFontFamily, useSettings } from "@/context/settings" import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal" import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
@@ -160,15 +160,13 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings() const settings = useSettings()
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session. const server = useServer()
const connection = useServerSDK()().server
const directory = sdk().directory const directory = sdk().directory
const client = sdk().client const client = sdk().client
const url = sdk().url const url = sdk().url
const auth = connection.http const auth = server.current?.http
const username = auth?.username ?? "opencode" const username = auth?.username ?? "opencode"
const password = auth?.password ?? "" const password = auth?.password ?? ""
const authToken = connection.type === "http" ? connection.authToken : false
const sameOrigin = new URL(url, location.href).origin === location.origin const sameOrigin = new URL(url, location.href).origin === location.origin
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
@@ -542,7 +540,7 @@ export const Terminal = (props: TerminalProps) => {
sameOrigin, sameOrigin,
username, username,
password, password,
authToken, authToken: server.current?.type === "http" ? server.current.authToken : false,
}), }),
) )
socket.binaryType = "arraybuffer" socket.binaryType = "arraybuffer"
@@ -1,19 +1,15 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { ServerConnection } from "@/context/server"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
const remote = "remote" as ServerConnection.Key
describe("titlebar session events", () => { describe("titlebar session events", () => {
test("reads valid removed session tab details", () => { test("reads valid removed session tab details", () => {
expect( expect(
readSessionTabsRemovedDetail( readSessionTabsRemovedDetail(
new CustomEvent(SESSION_TABS_REMOVED_EVENT, { new CustomEvent(SESSION_TABS_REMOVED_EVENT, {
detail: { server: "remote", directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] }, detail: { directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] },
}), }),
), ),
).toEqual({ ).toEqual({
server: remote,
directory: "/tmp/project", directory: "/tmp/project",
sessionIDs: ["ses_1", "ses_2"], sessionIDs: ["ses_1", "ses_2"],
}) })
@@ -1,9 +1,6 @@
import type { ServerConnection } from "@/context/server"
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed" export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
export type SessionTabsRemovedDetail = { export type SessionTabsRemovedDetail = {
server?: ServerConnection.Key
directory: string directory: string
sessionIDs: string[] sessionIDs: string[]
} }
@@ -21,14 +18,11 @@ export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDe
if (!("sessionIDs" in detail)) return undefined if (!("sessionIDs" in detail)) return undefined
if (typeof detail.directory !== "string") return undefined if (typeof detail.directory !== "string") return undefined
if (!Array.isArray(detail.sessionIDs)) return undefined if (!Array.isArray(detail.sessionIDs)) return undefined
if ("server" in detail && detail.server !== undefined && typeof detail.server !== "string") return undefined
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string") const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
if (sessionIDs.length === 0) return undefined if (sessionIDs.length === 0) return undefined
return { return {
server:
"server" in detail && typeof detail.server === "string" ? (detail.server as ServerConnection.Key) : undefined,
directory: detail.directory, directory: detail.directory,
sessionIDs, sessionIDs,
} }
@@ -1,33 +0,0 @@
import { describe, expect, test } from "bun:test"
import { canOpenTabRename, canStartTabDrag, forwardTabRef, isTabCloseTarget } from "./titlebar-tab-gesture"
describe("titlebar tab gestures", () => {
test("excludes close controls from tab gestures", () => {
const close = document.createElement("div")
const button = document.createElement("button")
const link = document.createElement("a")
close.dataset.slot = "tab-close"
close.append(button)
expect(isTabCloseTarget(close)).toBe(true)
expect(isTabCloseTarget(button)).toBe(true)
expect(isTabCloseTarget(link)).toBe(false)
})
test("forwards component refs", () => {
const element = document.createElement("div")
let received: HTMLDivElement | undefined
forwardTabRef((value) => (received = value), element)
expect(received).toBe(element)
})
test("does not reopen rename while a save is pending", () => {
expect(canOpenTabRename(false, false, false)).toBe(true)
expect(canOpenTabRename(false, false, true)).toBe(false)
})
test("preserves native panning for touch pointers", () => {
expect(canStartTabDrag("mouse")).toBe(true)
expect(canStartTabDrag("pen")).toBe(true)
expect(canStartTabDrag("touch")).toBe(false)
})
})
@@ -1,17 +0,0 @@
import type { Ref } from "solid-js"
export function isTabCloseTarget(target: EventTarget | null) {
return target instanceof Element && !!target.closest('[data-slot="tab-close"]')
}
export function canStartTabDrag(pointerType: string) {
return pointerType !== "touch"
}
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
if (typeof ref === "function") ref(element)
}
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
return !dragging && !editing && !committing
}
@@ -1,96 +0,0 @@
[data-titlebar-tab] [data-slot="tab-close"] {
position: absolute;
top: 4px;
right: 4px;
display: flex;
height: 20px;
width: 20px;
align-items: center;
justify-content: center;
}
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
display: none;
}
[data-titlebar-tab-list] {
gap: 13.5px;
}
[data-titlebar-tab-slot] {
position: relative;
}
[data-titlebar-tab-slot]:not(:first-child)::before {
content: "";
position: absolute;
top: 8px;
left: -6.75px;
width: 1.5px;
height: 12px;
border-radius: 9999px;
background: var(--v2-background-bg-layer-02);
}
[data-titlebar-tab] [data-slot="tab-close"]::before {
content: "";
position: absolute;
top: 0;
height: 28px;
width: 16px;
pointer-events: none;
background: linear-gradient(to right, transparent, var(--tab-bg));
display: none;
}
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
display: block;
right: 0;
}
[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before,
[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"])
[data-slot="tab-close"]::before {
right: 100%;
}
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] {
padding-right: 0;
}
[data-titlebar-tab] [data-slot="tab-title"] {
min-width: 0;
}
[data-titlebar-tab][data-editing="true"] [data-slot="tab-title"] {
min-width: 0;
max-width: 100%;
flex: 1 1 auto;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
scrollbar-width: none;
}
[data-titlebar-tab][data-editing="true"] [data-slot="tab-title"]::-webkit-scrollbar {
display: none;
}
@container (max-width: 64px) {
[data-titlebar-tab-link] {
justify-content: center;
gap: 0;
padding-inline: 0;
}
[data-titlebar-tab-title] {
display: none;
}
[data-slot="tab-close"] {
right: auto;
left: 50%;
transform: translateX(-50%);
}
}
@@ -1,355 +0,0 @@
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { showToast } from "@/utils/toast"
import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import "./titlebar-tab-nav.css"
export function TabNavItem(props: {
ref?: Ref<HTMLDivElement>
href: string
server: ServerConnection.Key
session: () => Session | undefined
onTitleChange?: (title: string) => void
onTitleChangeFailed?: (title: string) => void
onClose: () => void
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
suppressNavigation?: () => boolean
dragging?: boolean
pressed?: boolean
hidden?: boolean
}) {
const language = useLanguage()
const [editing, setEditing] = createSignal(false)
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
let tabRoot!: HTMLDivElement
let titleEl!: HTMLSpanElement
let committing = false
let measureFrame: number | undefined
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
const global = useGlobal()
const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.ensureServerCtx(conn)
})
const project = createMemo(() => {
const session = props.session()
if (!session) return
return projectForSession(session, serverCtx()?.projects.list() ?? [])
})
const measureTitleOverflow = () => {
if (!titleEl || editing()) {
setTitleOverflowing(false)
return
}
setTitleOverflowing(titleEl.scrollWidth > titleEl.clientWidth)
}
const scheduleTitleOverflow = () => {
if (measureFrame !== undefined) return
measureFrame = requestAnimationFrame(() => {
measureFrame = undefined
measureTitleOverflow()
})
}
createEffect(() => {
props.session()?.title
props.forceTruncate
editing()
scheduleTitleOverflow()
})
createResizeObserver(() => tabRoot, scheduleTitleOverflow)
onCleanup(() => {
if (measureFrame !== undefined) cancelAnimationFrame(measureFrame)
})
const selectTitle = () => {
const range = document.createRange()
range.selectNodeContents(titleEl)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}
const rename = async (title: string) => {
const ctx = serverCtx()
const session = props.session()
if (!ctx || !session) return
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
await client.session.update({ sessionID: session.id, title })
}
const closeRename = async (save: boolean) => {
if (committing || !editing()) return
committing = true
const original = props.session()?.title ?? ""
const next = (titleEl.textContent ?? "").trim()
titleEl.scrollLeft = 0
if (save && next && next !== original) props.onTitleChange?.(next)
setEditing(false)
if (!save || !next || next === original) {
committing = false
return
}
try {
await rename(next)
} catch (err) {
props.onTitleChangeFailed?.(original)
showToast({
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : undefined,
})
}
committing = false
}
createEffect(() => {
if (editing()) return
if (!titleEl) return
const title = props.session()?.title
if (title === undefined) return
titleEl.textContent = title
})
const openRename = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!canOpenTabRename(props.dragging, editing(), committing)) return
const session = props.session()
if (!session) return
titleEl.textContent = session.title
setEditing(true)
requestAnimationFrame(() => {
titleEl.focus()
selectTitle()
})
}
createEffect(() => {
if (!editing()) return
const cleanup = makeEventListener(
document,
"pointerdown",
(event) => {
const target = event.target
if (!(target instanceof Node)) return
if (tabRoot.contains(target)) return
void closeRename(true)
},
{ capture: true },
)
onCleanup(cleanup)
})
return (
<div
ref={(el) => {
tabRoot = el
forwardTabRef(props.ref, el)
}}
data-titlebar-tab
data-slot="titlebar-tab-item"
data-title-overflow={titleOverflowing()}
data-editing={editing()}
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
classList={{ invisible: props.hidden }}
data-active={props.active}
data-dragging={props.dragging}
data-pressed={props.pressed}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<Show when={props.session()}>
{(session) => {
return (
<a
data-slot="tab-link"
data-titlebar-tab-link
href={props.href}
draggable={false}
onDragStart={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
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 group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<span data-slot="project-avatar-slot">
<SessionTabAvatar
project={project()}
directory={session().directory}
sessionId={session().id}
activeServer={props.activeServer}
/>
</span>
<span
ref={(el) => {
titleEl = el
titleEl.textContent = session().title
}}
data-slot="tab-title"
data-titlebar-tab-title
class="min-w-0 flex-1 outline-none leading-4"
classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? true : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = session().title
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation()
}}
onClick={(event) => {
if (!editing()) return
event.preventDefault()
}}
/>
</a>
)
}}
</Show>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<IconButtonV2
size="small"
variant="ghost-muted"
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
/>
</div>
</div>
)
}
export function DraftTabItem(props: {
ref?: Ref<HTMLDivElement>
href: string
title: string
active?: boolean
onNavigate: () => void
onClose: () => void
suppressNavigation?: () => boolean
dragging?: boolean
pressed?: boolean
hidden?: boolean
}) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
return (
<div
ref={(el) => forwardTabRef(props.ref, el)}
data-titlebar-tab
data-slot="titlebar-tab-item"
data-active={props.active}
data-dragging={props.dragging}
data-pressed={props.pressed}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
classList={{ invisible: props.hidden }}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<a
data-slot="tab-link"
data-titlebar-tab-link
href={props.href}
draggable={false}
onDragStart={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
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 [-webkit-user-drag:none]"
>
<span class="flex size-4 shrink-0 items-center justify-center">
<IconV2 name="edit" />
</span>
<span
data-titlebar-tab-title
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
>
{props.title}
</span>
</a>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<IconButtonV2
size="small"
variant="ghost-muted"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
/>
</div>
</div>
)
}
@@ -1,331 +0,0 @@
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { arrayMove } from "@dnd-kit/helpers"
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
import { ServerConnection } from "@/context/server"
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
import { useGlobal, type ServerCtx } from "@/context/global"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs"
import { createTabPromptState } from "@/context/prompt"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
const sortableTransition = { duration: 0 }
function SessionTabSlot(props: {
tab: SessionTab
id: string
index: () => number
active: () => boolean
activeServerKey: ServerConnection.Key
forceTruncate: boolean
serverCtx: () => ServerCtx | undefined
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
}) {
const tabs = useTabs()
const sortable = useSortable({
get id() {
return props.id
},
get index() {
return props.index()
},
})
let ref!: HTMLDivElement
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
const [loadedSession] = createResource(
() => {
const ctx = props.serverCtx()
return ctx ? { id: props.tab.sessionId, ctx } : null
},
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
)
const session = createMemo(() => cachedSession() ?? loadedSession())
let prefetched = false
createEffect(() => {
const ctx = props.serverCtx()
const value = session()
if (!ctx || !value || prefetched) return
prefetched = true
createRoot((dispose) => {
try {
void ctx.sync
.ensureDirSyncContext(value.directory)
.session.sync(value.id)
.catch(() => {})
.finally(dispose)
} catch {
dispose()
}
})
})
createEffect(() => {
const value = session()
const current = sdk()
if (!value || !current) return
createTabPromptState(tabs, props.tab, current.scope, {
dir: base64Encode(value.directory),
id: value.id,
})
})
return (
<div
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ hidden: !session() }}
>
<TabNavItem
ref={(el) => {
ref = el
}}
href={tabHref(props.tab)}
server={props.tab.server}
session={session}
onTitleChange={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onTitleChangeFailed={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
activeServer={props.tab.server === props.activeServerKey}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
/>
</div>
)
}
function DraftTabSlot(props: {
tab: Extract<Tab, { type: "draft" }>
id: string
index: () => number
active: () => boolean
title: string
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
}) {
const sortable = useSortable({
get id() {
return props.id
},
get index() {
return props.index()
},
})
let ref!: HTMLDivElement
return (
<div
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
>
<DraftTabItem
ref={(el) => {
ref = el
}}
href={tabHref(props.tab)}
title={props.title}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
dragging={sortable.isDragSource()}
/>
</div>
)
}
export function TitlebarTabStrip(props: {
tabs: Tab[]
currentTab: () => Tab | undefined
activeServerKey: ServerConnection.Key
forceTruncate: boolean
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
onClose: (tab: Tab) => void
onReorder: (keys: string[]) => void
onOverflowChange: (overflowing: boolean) => void
}) {
const global = useGlobal()
const language = useLanguage()
let scrollRef!: HTMLDivElement
let listRef!: HTMLDivElement
let resizeFrame: number | undefined
const tabIds = () => props.tabs.map(tabKey)
function refreshOverflow() {
if (!scrollRef) return
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
}
createResizeObserver(
() => [scrollRef, listRef],
() => {
if (resizeFrame !== undefined) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
refreshOverflow()
})
},
)
onMount(() => {
refreshOverflow()
})
onCleanup(() => {
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
})
createEffect(() => {
props.tabs.length
tabIds()
refreshOverflow()
})
return (
<div data-slot="titlebar-tabs" class="relative min-w-0">
<div
data-slot="titlebar-tabs-scroll"
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
ref={scrollRef}
>
<DragDropProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
!canStartTabDrag(event.pointerType) ||
isTabCloseTarget(event.target) ||
(event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
}),
]}
modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => listRef })]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== Accessibility),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragStart={(event) => {
const source = event.operation.source
if (!source) return
const tab = props.tabs.find((item) => tabKey(item) === source.id.toString())
if (!tab) return
const tabEl = source.element?.querySelector<HTMLDivElement>("[data-titlebar-tab]")
props.onNavigate(tab, tabEl ?? undefined)
}}
onDragEnd={(event) => {
const current = tabIds()
const source = event.operation.source
if (event.canceled || !isSortable(source)) return
const { initialIndex, index } = source
if (initialIndex !== index) {
props.onReorder(arrayMove(current, source.initialIndex, source.index))
}
}}
>
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
<For each={props.tabs}>
{(tab, index) => {
const id = tabKey(tab)
let ref!: HTMLDivElement
useTabShortcut(index, () => props.onNavigate(tab, ref))
const serverCtx = createMemo(() => {
if (tab.type !== "session") return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
if (conn) return global.ensureServerCtx(conn)
})
if (tab.type === "session") {
return (
<SessionTabSlot
tab={tab}
id={id}
index={index}
active={() => props.currentTab() === tab}
activeServerKey={props.activeServerKey}
forceTruncate={props.forceTruncate}
serverCtx={serverCtx}
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
}}
onClose={() => props.onClose(tab)}
/>
)
}
return (
<DraftTabSlot
tab={tab}
id={id}
index={index}
active={() => props.currentTab() === tab}
title={language.t("command.session.new")}
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
}}
onClose={() => props.onClose(tab)}
/>
)
}}
</For>
</div>
</DragDropProvider>
</div>
<div
data-slot="titlebar-tabs-fade-left"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
/>
<div
data-slot="titlebar-tabs-fade-right"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
/>
</div>
)
}
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,
},
]
})
}
-31
View File
@@ -1,34 +1,3 @@
[data-slot="titlebar-tab-item"] {
user-select: none;
}
[data-slot="titlebar-tab-item"] a {
outline: none;
}
[data-slot="titlebar-v2"]
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:focus-visible, [data-state="focus"]):not(
:disabled
) {
outline: none;
background-color: var(--v2-overlay-simple-overlay-hover);
}
[data-slot="titlebar-tab-item"]
[data-component="icon-button-v2"]:is(:focus-visible, [data-state="focus"]):not(:disabled) {
opacity: 1;
}
[data-slot="titlebar-tab-item"]:has([data-component="icon-button-v2"]:focus-visible) [data-slot="titlebar-tab-close"] {
right: 0;
left: auto;
}
[data-slot="titlebar-tab-item"]:has([data-component="icon-button-v2"]:focus-visible)
[data-slot="titlebar-tab-close-fade"] {
background-image: var(--active-bg);
}
@keyframes titlebar-tab-fade-left { @keyframes titlebar-tab-fade-left {
from { from {
visibility: hidden; visibility: hidden;
+397 -56
View File
@@ -1,4 +1,16 @@
import { createEffect, createMemo, createResource, createSignal, Match, Show, Switch, untrack } from "solid-js" import {
createEffect,
createMemo,
createResource,
createRoot,
createSignal,
For,
Match,
onMount,
Show,
Switch,
untrack,
} from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -18,15 +30,19 @@ 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 { TitlebarTabStrip } from "@/components/titlebar-tab-strip" import { projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { ServerConnection, useServer } from "@/context/server" import { ServerConnection, useServer } from "@/context/server"
import { tabKey, useTabs } from "@/context/tabs" import { tabHref, useTabs } from "@/context/tabs"
import "./titlebar.css" import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind" 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>
@@ -99,8 +115,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const path = () => `${location.pathname}${location.search}${location.hash}` const path = () => `${location.pathname}${location.search}${location.hash}`
const creating = createMemo(() => { const creating = createMemo(() => {
const route = layout.route()
if (route.type === "draft" || route.type === "dir-new-sesssion") return true
if (!params.dir) return false if (!params.dir) return false
if (params.id) return false if (params.id) return false
const parts = location.pathname.replace(/\/+$/, "").split("/") const parts = location.pathname.replace(/\/+$/, "").split("/")
@@ -220,7 +234,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return ( return (
<header <header
data-slot={useV2Titlebar() ? "titlebar-v2" : undefined}
classList={{ classList={{
"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(),
@@ -243,6 +256,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<Switch> <Switch>
<Match when={useV2Titlebar()}> <Match when={useV2Titlebar()}>
{(_) => { {(_) => {
const navigate = useNavigate()
const layout = useLayout() const layout = useLayout()
const global = useGlobal() const global = useGlobal()
@@ -321,12 +335,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return return
} }
const activeTab = currentTab()
if (activeTab?.type === "draft") {
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "")
return
}
const current = layout.projects.list()[0] const current = layout.projects.list()[0]
if (current) { if (current) {
tabs.newDraft({ server: server.key, directory: current.worktree }, "") tabs.newDraft({ server: server.key, directory: current.worktree }, "")
@@ -380,7 +388,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
id: `tab.prev`, id: `tab.prev`,
category: "tab", category: "tab",
title: "", title: "",
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`, keybind: `mod+option+ArrowLeft`,
hidden: true, hidden: true,
onSelect: () => { onSelect: () => {
let index = tabsStore.findIndex((tab) => tab === currentTab()) let index = tabsStore.findIndex((tab) => tab === currentTab())
@@ -397,7 +405,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
id: `tab.next`, id: `tab.next`,
category: "tab", category: "tab",
title: "", title: "",
keybind: `mod+option+ArrowRight,ctrl+tab`, keybind: `mod+option+ArrowRight`,
hidden: true, hidden: true,
onSelect: () => { onSelect: () => {
let index = tabsStore.findIndex((tab) => tab === currentTab()) let index = tabsStore.findIndex((tab) => tab === currentTab())
@@ -414,6 +422,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}) })
const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false) const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false)
let tabScrollRef!: HTMLDivElement
function refreshTabsAreOverflowing() {
setTabsAreOverflowing(tabScrollRef.scrollWidth > tabScrollRef.clientWidth)
}
return ( return (
<div <div
@@ -452,42 +465,171 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
/> />
</TooltipV2> </TooltipV2>
<TitlebarTabStrip <div data-slot="titlebar-tabs" class="relative min-w-0">
tabs={tabsStore} <div
currentTab={currentTab} data-slot="titlebar-tabs-scroll"
activeServerKey={server.key} class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
forceTruncate={tabsAreOverflowing()} ref={(el) => {
onOverflowChange={setTabsAreOverflowing} tabScrollRef = el
onNavigate={(tab, el) => { createResizeObserver(el, refreshTabsAreOverflowing)
tabs.select(tab) }}
el?.scrollIntoView({ behavior: "instant" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.removeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Show when={!creating()}>
<TooltipV2
placement="bottom"
value={
<>
{language.t("command.session.new")}
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
> >
<IconButtonV2 <div
type="button" class="flex min-w-0 flex-row items-center gap-1.5"
variant="ghost-muted" ref={(el) => createResizeObserver(el, refreshTabsAreOverflowing)}
size="large" >
class="shrink-0" <For each={tabsStore}>
icon={<IconV2 name="plus" />} {(tab, i) => {
onClick={openNewTab} let ref!: HTMLDivElement
aria-label={language.t("command.session.new")} useTabShortcut(i, () => tabs.select(tab))
/>
</TooltipV2> const divider = () =>
i() !== 0 && (
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
)
if (tab.type === "draft") {
return (
<>
{divider()}
<DraftTabItem
ref={ref}
href={tabHref(tab)}
title={language.t("command.session.new")}
active={currentTab() === tab}
onNavigate={() => {
tabs.select(tab)
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
/>
</>
)
}
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 (
<>
{divider()}
<Show when={session()}>
{(session) => (
<TabNavItem
ref={ref}
href={tabHref(tab)}
server={tab.server}
sessionId={tab.sessionId}
session={session()}
onNavigate={() => {
tabs.select(tab)
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
active={currentTab() === tab}
activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()}
/>
)}
</Show>
</>
)
}}
</For>
<Show when={creating() && params.dir}>
{(_) => {
let ref!: HTMLDivElement
onMount(() => {
ref.scrollIntoView({ behavior: "instant" })
})
return (
<>
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
<NewSessionTabItem
ref={ref}
href={`/${params.dir}/session`}
title={language.t("command.session.new")}
onClose={() => {
const tab = tabsStore.at(-1)
if (tab) tabs.select(tab)
else navigate("/")
}}
/>
</>
)
}}
</Show>
</div>
</div>
<div
data-slot="titlebar-tabs-fade-left"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
/>
<div
data-slot="titlebar-tabs-fade-right"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
/>
</div>
<Show when={!(creating() && params.dir)}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</Show> </Show>
<div class="flex-1" /> <div class="flex-1" />
<TitlebarV2Right state={v2RightState()} /> <TitlebarV2Right state={v2RightState()} />
@@ -575,6 +717,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
> >
<Button <Button
variant="ghost" variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border" class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()} disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined} tabIndex={layout.sidebar.opened() ? -1 : undefined}
@@ -584,9 +727,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}} }}
aria-label={language.t("command.session.new")} aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined} aria-current={creating() ? "page" : undefined}
> />
<IconV2 name="edit" size="small" />
</Button>
</TooltipKeybind> </TooltipKeybind>
</div> </div>
</div> </div>
@@ -684,16 +825,16 @@ function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) { function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
return ( return (
<div class="group relative mr-3 h-5 w-5 shrink-0 rounded-full bg-v2-background-bg-deep transition-[width] duration-150 ease-out hover:z-30 hover:w-[68px] focus-within:z-30 focus-within:w-[68px] motion-reduce:transition-none"> <div class="relative isolate mr-3 size-5 shrink-0">
<button <button
type="button" type="button"
class="absolute right-0 top-0 z-10 flex h-5 w-5 items-center justify-end overflow-hidden rounded-full bg-v2-icon-icon-accent/20 text-v2-icon-icon-accent transition-[width,background-color] duration-150 ease-out group-hover:w-[68px] group-hover:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] group-focus-within:w-[68px] group-focus-within:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:outline-none disabled:opacity-60 motion-reduce:transition-none" class="group absolute right-0 top-0 z-10 flex h-5 w-5 items-center justify-end overflow-hidden rounded-full bg-v2-icon-icon-accent/20 text-v2-icon-icon-accent transition-[width,background-color] duration-150 ease-out hover:z-30 hover:w-[68px] hover:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:z-30 focus-visible:w-[68px] focus-visible:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:outline-none disabled:opacity-60 motion-reduce:transition-none"
onClick={props.state.onInstall} onClick={props.state.onInstall}
disabled={props.state.installing} disabled={props.state.installing}
aria-busy={props.state.installing} aria-busy={props.state.installing}
aria-label={props.state.ariaLabel} aria-label={props.state.ariaLabel}
> >
<span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0"> <span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-visible:opacity-100 group-focus-visible:translate-x-0 motion-reduce:translate-x-0">
Update Update
</span> </span>
<span class="flex size-5 shrink-0 items-center justify-center"> <span class="flex size-5 shrink-0 items-center justify-center">
@@ -711,6 +852,206 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
) )
} }
function useTabShortcut(index: () => number, onSelect: () => void) {
const command = useCommand()
command.register(() => {
const number = index() + 1
if (number > 9) return []
return [
{
id: `tab.${number}`,
category: "tab",
title: "",
keybind: `mod+${number}`,
hidden: true,
onSelect,
},
]
})
}
function TabNavItem(props: {
ref?: HTMLDivElement
href: string
server: ServerConnection.Key
sessionId?: string
hideClose?: boolean
onClose: () => void
onNavigate: () => void
active?: boolean
activeServer: boolean
forceTruncate?: boolean
session: Session
}) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
const global = useGlobal()
const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.ensureServerCtx(conn)
})
return (
<div
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)]"
data-active={props.active}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<Show when={props.session}>
{(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
return (
<a
href={props.href}
onClick={(event) => {
event.preventDefault()
props.onNavigate()
}}
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">
<SessionTabAvatar
project={project()}
directory={session().directory}
sessionId={session().id}
activeServer={props.activeServer}
/>
</span>
<span class="min-w-0 flex-1">{session().title}</span>
</a>
)
}}
</Show>
<div
class="absolute not-group-hover:not-group-data-[active=true]:not-data-[truncate=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 data-[truncate=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2"
data-truncate={props.forceTruncate}
>
<div
class="absolute inset-0 rounded-r-[6px] bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
style={{
"--inactive-bg": "linear-gradient(to right, transparent 0%, var(--tab-bg) 80%)",
"--active-bg": "linear-gradient(90deg, transparent 0%, var(--tab-bg) 25%)",
}}
/>
<IconButtonV2
size="small"
variant="ghost-muted"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
/>
</div>
</div>
)
}
function DraftTabItem(props: {
ref?: HTMLDivElement
href: string
title: string
active?: boolean
onNavigate: () => void
onClose: () => void
}) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
return (
<div
ref={props.ref}
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)]"
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<a
href={props.href}
onClick={(event) => {
event.preventDefault()
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
>
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
<IconV2 name="edit" />
</span>
<span class="truncate leading-5">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
<IconButtonV2
size="small"
variant="ghost-muted"
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
/>
</div>
</div>
)
}
function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: string; onClose: () => void }) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
return (
<div
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)]"
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<a
href={props.href}
aria-current="page"
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-[var(--v2-text-text-base)]"
>
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
<IconV2 name="edit" />
</span>
<span class="truncate leading-5">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
<IconButtonV2
size="small"
variant="ghost-muted"
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
/>
</div>
</div>
)
}
function ChannelIndicator() { function ChannelIndicator() {
return ( return (
<> <>
-5
View File
@@ -227,11 +227,6 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
return IS_MAC ? parts.join("") : parts.join("+") return IS_MAC ? parts.join("") : parts.join("+")
} }
// KeybindV2 takes an array instead of a string
export function formatKeybindKeys(config: string, t?: (key: KeyLabel) => string): string[] {
return formatKeybindParts(config, t)
}
function isEditableTarget(target: EventTarget | null) { function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true if (target.isContentEditable) return true
+1 -5
View File
@@ -1,5 +1,5 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { produce, reconcile, type SetStoreFunction } from "solid-js/store" import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk" import type { createServerSdkContext } from "./server-sdk"
@@ -73,10 +73,6 @@ export const createDirSyncContext = (
if (match.found) return serverSync.data.project[match.index] if (match.found) return serverSync.data.project[match.index]
}, },
session: { session: {
remember(session: Session) {
serverSync.session.remember(session)
index(session.id)
},
get(sessionID: string) { get(sessionID: string) {
const session = serverSync.session.get(sessionID) const session = serverSync.session.get(sessionID)
if (session?.directory === directory) return session if (session?.directory === directory) return session
+1 -17
View File
@@ -1,4 +1,4 @@
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { useLocation } from "@solidjs/router" import { useLocation } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -17,7 +17,6 @@ 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" import { requireServerKey } from "@/utils/session-route"
import { type DraftTab, useTabs } from "./tabs"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -74,7 +73,6 @@ type TabHandoff = {
} }
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean } export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export type ReviewDiffStyle = "unified" | "split" export type ReviewDiffStyle = "unified" | "split"
@@ -160,17 +158,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
const server = useServer() const server = useServer()
const tabs = useTabs()
const platform = usePlatform() const platform = usePlatform()
const location = useLocation() const location = useLocation()
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 if (value.server) return value
if (value.type === "draft") {
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === value.draftID)
if (draft) return { ...value, server: draft.server }
}
return { ...value, server: server.key } return { ...value, server: server.key }
}) })
@@ -297,9 +290,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
handoff: { handoff: {
tabs: undefined as TabHandoff | undefined, tabs: undefined as TabHandoff | undefined,
}, },
home: {
selection: { server: server.key } as HomeProjectSelection,
},
}), }),
) )
@@ -589,12 +579,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return { return {
route, route,
ready, ready,
home: {
selection: createMemo(() => store.home.selection),
setSelection(selection: HomeProjectSelection) {
setStore("home", "selection", reconcile(selection))
},
},
handoff: { handoff: {
tabs: createMemo(() => store.handoff?.tabs), tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) { setTabs(dir: string, id: string) {
+28 -32
View File
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js" import { batch, createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
@@ -60,7 +60,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().directory) const providers = useProviders()
const models = useModels() const models = useModels()
const id = createMemo(() => params.id || undefined) const id = createMemo(() => params.id || undefined)
@@ -294,21 +294,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model.set({ providerID: entry.provider.id, modelID: entry.id }) model.set({ providerID: entry.provider.id, modelID: entry.id })
}, },
set(item: ModelKey | undefined, options?: { recent?: boolean }) { set(item: ModelKey | undefined, options?: { recent?: boolean }) {
startTransition(() => batch(() => {
batch(() => { setStore("last", {
setStore("last", { type: "model",
type: "model", agent: agent.current()?.name,
agent: agent.current()?.name, model: item ?? null,
model: item ?? null, variant: selected(),
variant: selected(), })
}) write({ model: item })
write({ model: item }) if (!item) return
if (!item) return models.setVisibility(item, true)
models.setVisibility(item, true) if (!options?.recent) return
if (!options?.recent) return models.recent.push(item)
models.recent.push(item) })
}),
)
}, },
visible(item: ModelKey) { visible(item: ModelKey) {
return models.visible(item) return models.visible(item)
@@ -337,21 +335,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return Object.keys(item.variants) return Object.keys(item.variants)
}, },
set(value: string | undefined) { set(value: string | undefined) {
startTransition(() => batch(() => {
batch(() => { const model = current()
const model = current() setStore("last", {
setStore("last", { type: "variant",
type: "variant", agent: agent.current()?.name,
agent: agent.current()?.name, model: model ? { providerID: model.provider.id, modelID: model.id } : null,
model: model ? { providerID: model.provider.id, modelID: model.id } : null, variant: value ?? null,
variant: value ?? null, })
}) write({ variant: value ?? null })
write({ variant: value ?? null }) if (model) {
if (model) { models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined) }
} })
}),
)
}, },
cycle() { cycle() {
const items = this.list() const items = this.list()
+4 -13
View File
@@ -1,4 +1,4 @@
import { type Accessor, createMemo, createResource } from "solid-js" import { createMemo } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda" import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
@@ -25,8 +25,8 @@ function modelKey(model: ModelKey) {
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({ export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
name: "Models", name: "Models",
gate: false, gate: false,
init: (props: { directory?: Accessor<string | undefined> } = {}) => { init: () => {
const providers = useProviders(props.directory) const providers = useProviders()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("model", ["model.v1"]), Persist.global("model", ["model.v1"]),
@@ -145,15 +145,6 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
setStore("variant", key, value) setStore("variant", key, value)
} }
const [recentModels] = createResource(
async () => {
const recent = store.recent
await ready.promise
return recent
},
(p) => p,
{ initialValue: [] },
)
return { return {
ready, ready,
list, list,
@@ -161,7 +152,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
visible, visible,
setVisibility, setVisibility,
recent: { recent: {
list: () => recentModels()!, list: createMemo(() => store.recent),
push, push,
}, },
variant: { variant: {
+245 -343
View File
@@ -1,9 +1,9 @@
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { useParams, useSearchParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import type { ServerSync } from "./server-sync" 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"
@@ -12,11 +12,6 @@ import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2" import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { playSoundById } from "@/utils/sound" import { playSoundById } from "@/utils/sound"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { type DraftTab, useTabs } from "./tabs"
import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
type NotificationBase = { type NotificationBase = {
directory?: string directory?: string
@@ -112,360 +107,267 @@ function buildNotificationIndex(list: Notification[]) {
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification", name: "Notification",
gate: false, gate: false,
init: () => { init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>() const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>() const serverSDK = useServerSDK()
const global = useGlobal() const serverSync = useServerSync()
const server = useServer()
const tabs = useTabs()
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings() const settings = useSettings()
const language = useLanguage() const language = useLanguage()
const owner = getOwner()
const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
const activeServer = createMemo(() => { const empty: Notification[] = []
if (params.serverKey) return requireServerKey(params.serverKey)
if (search.draftId) { const currentDirectory = createMemo(() => {
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId) return props.directory?.() ?? decode64(params.dir)
if (draft) return draft.server
}
return server.key
}) })
const activeDirectory = createMemo(() => decode64(params.dir))
const activeSession = createMemo(() => params.id)
const ensure = (key: ServerConnection.Key) => { const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
if (!conn) throw new Error(`Notification server not found: ${key}`) const [store, setStore, _, ready] = persisted(
const ctx = global.ensureServerCtx(conn) Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
const existing = states.get(ctx.sdk.scope) createStore({
if (existing) return existing.state list: [] as Notification[],
const root = createRoot( }),
(dispose) => ({ )
dispose, const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
state: createServerNotificationState({
sdk: ctx.sdk, const meta = { pruned: false, disposed: false }
sync: ctx.sync,
active: () => server.scope(activeServer()) === ctx.sdk.scope, const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
directory: activeDirectory, setIndex(scope, "unseen", key, unseen)
sessionID: activeSession, setIndex(scope, "unseenCount", key, unseen.length)
platform, setIndex(
settings, scope,
language, "unseenHasError",
}), key,
}), unseen.some((notification) => notification.type === "error"),
owner ?? undefined,
) )
states.set(ctx.sdk.scope, root) }
return root.state
const appendToIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
}
}
}
const removeFromIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
updateUnseen("session", notification.session, unseen)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
updateUnseen("project", notification.directory, unseen)
}
}
} }
createEffect(() => { createEffect(() => {
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn))) if (!ready()) return
}) if (meta.pruned) return
meta.pruned = true
createEffect(() => { const list = pruneNotifications(store.list)
const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn)))) batch(() => {
states.forEach((value, scope) => { setStore("list", list)
if (scopes.has(scope)) return setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
value.dispose()
states.delete(scope)
}) })
}) })
onCleanup(() => states.forEach((value) => value.dispose())) const append = (notification: Notification) => {
const list = pruneNotifications([...store.list, notification])
const keep = new Set(list)
const removed = store.list.filter((n) => !keep.has(n))
const selected = () => ensure(activeServer()) batch(() => {
if (keep.has(notification)) appendToIndex(notification)
removed.forEach((n) => removeFromIndex(n))
setStore("list", list)
})
}
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const sync = serverSync().ensureDirSyncContext(directory)
const session = sync.session.get(sessionID)
if (session) return session
return sync.session
.sync(sessionID)
.then(() => sync.session.get(sessionID))
.catch(() => undefined)
}
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
const activeDirectory = currentDirectory()
const activeSession = currentSession()
if (!activeDirectory) return false
if (!activeSession) return false
if (!sessionID) return false
if (directory !== activeDirectory) return false
return sessionID === activeSession
}
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
if (session.parentID) return
if (settings.sounds.agentEnabled()) {
void playSoundById(settings.sounds.agent())
}
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "turn-complete",
session: sessionID,
})
const href = `/${base64Encode(directory)}/session/${sessionID}`
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
}
})
}
const handleSessionError = (
directory: string,
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
time: number,
) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
void playSoundById(settings.sounds.errors())
}
const error = "error" in event.properties ? event.properties.error : undefined
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "error",
session: sessionID ?? "global",
error,
})
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, href)
}
})
}
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name
const time = Date.now()
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
return
}
handleSessionError(directory, event, time)
})
onCleanup(() => {
meta.disposed = true
unsub()
})
return { return {
ready: () => selected().ready(), ready,
ensureServerState: ensure,
session: { session: {
all: (session: string) => selected().session.all(session), all(session: string) {
unseen: (session: string) => selected().session.unseen(session), return index.session.all[session] ?? empty
unseenCount: (session: string) => selected().session.unseenCount(session), },
unseenHasError: (session: string) => selected().session.unseenHasError(session), unseen(session: string) {
markViewed: (session: string) => selected().session.markViewed(session), return index.session.unseen[session] ?? empty
},
unseenCount(session: string) {
return index.session.unseenCount[session] ?? 0
},
unseenHasError(session: string) {
return index.session.unseenHasError[session] ?? false
},
markViewed(session: string) {
const unseen = index.session.unseen[session] ?? empty
if (!unseen.length) return
const projects = [
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
]
batch(() => {
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
updateUnseen("session", session, [])
projects.forEach((directory) => {
const next = (index.project.unseen[directory] ?? empty).filter(
(notification) => notification.session !== session,
)
updateUnseen("project", directory, next)
})
})
},
}, },
project: { project: {
all: (directory: string) => selected().project.all(directory), all(directory: string) {
unseen: (directory: string) => selected().project.unseen(directory), return index.project.all[directory] ?? empty
unseenCount: (directory: string) => selected().project.unseenCount(directory), },
unseenHasError: (directory: string) => selected().project.unseenHasError(directory), unseen(directory: string) {
markViewed: (directory: string) => selected().project.markViewed(directory), return index.project.unseen[directory] ?? empty
},
unseenCount(directory: string) {
return index.project.unseenCount[directory] ?? 0
},
unseenHasError(directory: string) {
return index.project.unseenHasError[directory] ?? false
},
markViewed(directory: string) {
const unseen = index.project.unseen[directory] ?? empty
if (!unseen.length) return
const sessions = [
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
]
batch(() => {
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
updateUnseen("project", directory, [])
sessions.forEach((session) => {
const next = (index.session.unseen[session] ?? empty).filter(
(notification) => notification.directory !== directory,
)
updateUnseen("session", session, next)
})
})
},
}, },
} }
}, },
}) })
type NotificationState = ReturnType<typeof createServerNotificationState>
function createServerNotificationState(input: {
sdk: ServerSDK
sync: ServerSync
active: Accessor<boolean>
directory: Accessor<string | undefined>
sessionID: Accessor<string | undefined>
platform: ReturnType<typeof usePlatform>
settings: ReturnType<typeof useSettings>
language: ReturnType<typeof useLanguage>
}) {
const serverSDK = () => input.sdk
const serverSync = () => input.sync
const platform = input.platform
const settings = input.settings
const language = input.language
const empty: Notification[] = []
const currentDirectory = input.directory
const currentSession = input.sessionID
const [store, setStore, _, ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
createStore({
list: [] as Notification[],
}),
)
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
const meta = { pruned: false, disposed: false }
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
setIndex(scope, "unseen", key, unseen)
setIndex(scope, "unseenCount", key, unseen.length)
setIndex(
scope,
"unseenHasError",
key,
unseen.some((notification) => notification.type === "error"),
)
}
const appendToIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
}
}
}
const removeFromIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
updateUnseen("session", notification.session, unseen)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
updateUnseen("project", notification.directory, unseen)
}
}
}
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
const list = pruneNotifications(store.list)
batch(() => {
setStore("list", list)
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
})
})
const append = (notification: Notification) => {
const list = pruneNotifications([...store.list, notification])
const keep = new Set(list)
const removed = store.list.filter((n) => !keep.has(n))
batch(() => {
if (keep.has(notification)) appendToIndex(notification)
removed.forEach((n) => removeFromIndex(n))
setStore("list", list)
})
}
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const sync = serverSync().ensureDirSyncContext(directory)
const session = sync.session.get(sessionID)
if (session) return session
return sync.session
.sync(sessionID)
.then(() => sync.session.get(sessionID))
.catch(() => undefined)
}
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
if (!input.active()) return false
const activeDirectory = currentDirectory()
const activeSession = currentSession()
if (!activeSession) return false
if (!sessionID) return false
if (activeDirectory && directory !== activeDirectory) return false
return sessionID === activeSession
}
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
if (session.parentID) return
if (settings.sounds.agentEnabled()) {
void playSoundById(settings.sounds.agent())
}
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "turn-complete",
session: sessionID,
})
const href = `/${base64Encode(directory)}/session/${sessionID}`
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
}
})
}
const handleSessionError = (
directory: string,
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
time: number,
) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
void playSoundById(settings.sounds.errors())
}
const error = "error" in event.properties ? event.properties.error : undefined
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "error",
session: sessionID ?? "global",
error,
})
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, href)
}
})
}
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name
const time = Date.now()
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
return
}
handleSessionError(directory, event, time)
})
onCleanup(() => {
meta.disposed = true
unsub()
})
return {
ready,
session: {
all(session: string) {
return index.session.all[session] ?? empty
},
unseen(session: string) {
return index.session.unseen[session] ?? empty
},
unseenCount(session: string) {
return index.session.unseenCount[session] ?? 0
},
unseenHasError(session: string) {
return index.session.unseenHasError[session] ?? false
},
markViewed(session: string) {
const unseen = index.session.unseen[session] ?? empty
if (!unseen.length) return
const projects = [
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
]
batch(() => {
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
updateUnseen("session", session, [])
projects.forEach((directory) => {
const next = (index.project.unseen[directory] ?? empty).filter(
(notification) => notification.session !== session,
)
updateUnseen("project", directory, next)
})
})
},
},
project: {
all(directory: string) {
return index.project.all[directory] ?? empty
},
unseen(directory: string) {
return index.project.unseen[directory] ?? empty
},
unseenCount(directory: string) {
return index.project.unseenCount[directory] ?? 0
},
unseenHasError(directory: string) {
return index.project.unseenHasError[directory] ?? false
},
markViewed(directory: string) {
const unseen = index.project.unseen[directory] ?? empty
if (!unseen.length) return
const sessions = [
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
]
batch(() => {
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
updateUnseen("project", directory, [])
sessions.forEach((session) => {
const next = (index.session.unseen[session] ?? empty).filter(
(notification) => notification.directory !== directory,
)
updateUnseen("session", session, next)
})
})
},
},
}
}
+20 -20
View File
@@ -9,7 +9,7 @@ import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs" import { useTabs, type Tab } from "./tabs"
import { ServerConnection } from "./server" import { useServer } from "./server"
import { requireServerKey } from "@/utils/session-route" import { requireServerKey } from "@/utils/session-route"
import { useSettings } from "./settings" import { useSettings } from "./settings"
@@ -169,15 +169,6 @@ type PromptStore = {
type Scope = { draftID: string } | { dir: string; id?: string } type Scope = { draftID: string } | { dir: string; id?: string }
export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) {
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
if (!scope.id) return
return (
tabs.find((tab) => tab.type === "session" && tab.server === server && tab.sessionId === scope.id) ??
({ type: "session", server, sessionId: scope.id } satisfies Tab)
)
}
function scopeKey(scope: Scope) { function scopeKey(scope: Scope) {
if ("draftID" in scope) return `draft:${scope.draftID}` if ("draftID" in scope) return `draft:${scope.draftID}`
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}` return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
@@ -222,7 +213,7 @@ function promptStore(): PromptStore {
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) { function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
const actions = createPromptActions(setStore) const actions = createPromptActions(setStore)
const value = { return {
current: () => store.prompt, current: () => store.prompt,
cursor: createMemo(() => store.cursor), cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
@@ -259,9 +250,7 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<P
}, },
set: actions.set, set: actions.set,
reset: actions.reset, reset: actions.reset,
capture: () => value,
} }
return value
} }
export function createPromptState() { export function createPromptState() {
@@ -287,6 +276,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const sdk = useSDK() 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 tabs = useTabs()
const settings = useSettings() const settings = useSettings()
const cache = new Map<string, PromptCacheEntry>() const cache = new Map<string, PromptCacheEntry>()
@@ -311,12 +301,21 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
const owner = getOwner() const owner = getOwner()
const serverKey = () => const tab = createMemo<Tab | undefined>(() => {
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server) if (!settings.general.newLayoutDesigns()) return
const scope = () => if (search.draftId) {
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id } 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 = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined const current = tab()
if (current) { if (current) {
return createTabPromptState(tabs, current, serverSDK().scope, scope) return createTabPromptState(tabs, current, serverSDK().scope, scope)
} }
@@ -342,13 +341,14 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return entry.value return entry.value
} }
const session = createMemo(() => load(scope())) const session = createMemo(() =>
load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
)
const pick = (scope?: Scope) => (scope ? load(scope) : session()) const pick = (scope?: Scope) => (scope ? load(scope) : session())
const ready = createPromptReady(session) const ready = createPromptReady(session)
return { return {
ready, ready,
capture: (scope?: Scope) => pick(scope).capture(),
current: () => session().current(), current: () => session().current(),
cursor: () => session().cursor(), cursor: () => session().cursor(),
dirty: () => session().dirty(), dirty: () => session().dirty(),
+8 -115
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
import type { Event } from "@opencode-ai/sdk/v2/client" import type { Event } from "@opencode-ai/sdk/v2/client"
describe("resumeStreamAfterPageShow", () => { describe("resumeStreamAfterPageShow", () => {
@@ -15,23 +15,19 @@ describe("resumeStreamAfterPageShow", () => {
}) })
describe("coalesceServerEvents", () => { describe("coalesceServerEvents", () => {
const delta = (value: string, field = "text", partID = "part") => ({ const delta = (value: string, field = "text") => ({
directory: "/repo", directory: "/repo",
payload: { payload: {
type: "message.part.delta", type: "message.part.delta",
properties: { messageID: "msg", partID, field, delta: value }, properties: { messageID: "msg", partID: "part", field, delta: value },
} as Event, } as Event,
}) })
test("merges adjacent deltas for the same field", () => { test("merges adjacent deltas for the same field", () => {
const first = delta("hello ") const result = coalesceServerEvents([delta("hello "), delta("world")])
const second = delta("world")
first.payload.id = "first"
second.payload.id = "second"
const result = coalesceServerEvents([first, second])
expect(result).toHaveLength(1) expect(result).toHaveLength(1)
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } }) expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } })
}) })
test("preserves event boundaries and distinct fields", () => { test("preserves event boundaries and distinct fields", () => {
@@ -49,112 +45,9 @@ describe("coalesceServerEvents", () => {
]) ])
}) })
test("preserves event ID order across interleaved deltas", () => { test("drops stale deltas", () => {
const first = delta("a") const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"]))
const other = delta("b", "text", "other")
const last = delta("c")
first.payload.id = "1"
other.payload.id = "2"
last.payload.id = "3"
const result = coalesceServerEvents([first, other, last]) expect(result).toEqual([])
expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"])
})
})
describe("enqueueServerEvent", () => {
const partUpdated = (text: string) =>
({
type: "message.part.updated",
properties: {
sessionID: "session",
part: { id: "part", sessionID: "session", messageID: "message", type: "text", text },
},
}) as Event
test("preserves part updates across message remove and re-add barriers", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old"))
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
enqueue({
type: "message.updated",
properties: {
sessionID: "session",
info: {
id: "message",
sessionID: "session",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "provider", modelID: "model" },
},
},
} as Event)
enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([
"message.part.updated",
"message.removed",
"message.updated",
"message.part.updated",
])
})
test("preserves deltas after a replacement snapshot", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("a"))
enqueue(partUpdated("ab"))
enqueue({
type: "message.part.delta",
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
} as Event)
const result = coalesceServerEvents(events)
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
})
test("preserves updates after session deletion", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old"))
enqueue({
type: "session.deleted",
properties: { sessionID: "session", info: { id: "session" } },
} as Event)
enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([
"message.part.updated",
"session.deleted",
"message.part.updated",
])
})
test("does not coalesce edge-triggered session statuses", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (status: "retry" | "busy") =>
enqueueServerEvent(events, {
directory: "/repo",
payload: {
type: "session.status",
properties: {
sessionID: "session",
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
},
} as Event,
})
enqueue("retry")
enqueue("busy")
expect(events).toHaveLength(2)
}) })
}) })
+50 -42
View File
@@ -17,56 +17,34 @@ const isAbortError = (error: unknown) =>
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
type QueuedServerEvent = { directory: string; payload: Event } type QueuedServerEvent = { directory: string; payload: Event }
const coalescedKey = (event: QueuedServerEvent) => { const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
if (event.payload.type === "message.part.updated") {
const part = event.payload.properties.part
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
}
return undefined
}
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) { export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set<string>) {
const key = coalescedKey(event)
const previous = queue[queue.length - 1]
if (key && previous && coalescedKey(previous) === key) {
queue[queue.length - 1] = event
return false
}
queue.push(event)
return true
}
export function coalesceServerEvents(events: QueuedServerEvent[]) {
const output: QueuedServerEvent[] = [] const output: QueuedServerEvent[] = []
const deltas = new Map<string, number>()
events.forEach((event) => { events.forEach((event) => {
if (stale && event.payload.type === "message.part.delta") {
const props = event.payload.properties
if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return
}
if (event.payload.type !== "message.part.delta") { if (event.payload.type !== "message.part.delta") {
deltas.clear()
output.push(event) output.push(event)
return return
} }
const props = event.payload.properties const props = event.payload.properties
const previous = output[output.length - 1] const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}`
if ( const index = deltas.get(id)
!previous || const existing = index === undefined ? undefined : output[index]
previous.payload.type !== "message.part.delta" || if (!existing || existing.payload.type !== "message.part.delta") {
previous.directory !== event.directory || deltas.set(id, output.length)
previous.payload.properties.messageID !== props.messageID ||
previous.payload.properties.partID !== props.partID ||
previous.payload.properties.field !== props.field
) {
output.push({ output.push({
directory: event.directory, directory: event.directory,
payload: { ...event.payload, properties: { ...props } }, payload: { ...event.payload, properties: { ...props } },
}) })
return return
} }
output[output.length - 1] = { existing.payload.properties.delta += props.delta
directory: event.directory,
payload: {
...event.payload,
properties: { ...props, delta: previous.payload.properties.delta + props.delta },
},
}
}) })
return output return output
} }
@@ -107,9 +85,20 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
let queue: Queued[] = [] let queue: Queued[] = []
let buffer: Queued[] = [] let buffer: Queued[] = []
const coalesced = new Map<string, number>()
const staleDeltas = new Set<string>()
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
let last = 0 let last = 0
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
if (payload.type === "message.part.updated") {
const part = payload.properties.part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
}
}
const flush = () => { const flush = () => {
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer)
timer = undefined timer = undefined
@@ -117,12 +106,15 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
if (queue.length === 0) return if (queue.length === 0) return
const events = queue const events = queue
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
queue = buffer queue = buffer
buffer = events buffer = events
queue.length = 0 queue.length = 0
coalesced.clear()
staleDeltas.clear()
last = Date.now() last = Date.now()
const output = coalesceServerEvents(events) const output = coalesceServerEvents(events, skip)
batch(() => { batch(() => {
output.forEach((event) => emitter.emit(event.directory, event.payload)) output.forEach((event) => emitter.emit(event.directory, event.payload))
}) })
@@ -192,12 +184,29 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
for await (const event of events.stream) { for await (const event of events.stream) {
resetHeartbeat() resetHeartbeat()
streamErrorLogged = false streamErrorLogged = false
if (event.payload.type !== "sync") { const directory = event.directory ?? "global"
const directory = event.directory ?? "global" if (event.payload.type === "sync") {
const payload = event.payload as Event continue
if (enqueueServerEvent(queue, { directory, payload })) schedule()
} }
const payload = event.payload as Event
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now() yielded = Date.now()
await wait(0) await wait(0)
@@ -260,7 +269,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}) })
return { return {
server,
scope, scope,
url: server.http.url, url: server.http.url,
client: sdk, client: sdk,
File diff suppressed because it is too large Load Diff
+70 -549
View File
@@ -18,67 +18,44 @@ import { rootSession } from "@/utils/session-route"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" 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 cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 2 const initialMessagePageSize = 2
const historyMessagePageSize = 200 const historyMessagePageSize = 200
const sessionInfoLimit = 2_048 const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set()
type OptimisticItem = { type OptimisticItem = {
message: Message message: Message
parts: Part[] parts: Part[]
confirmedParts?: Part[]
confirmedMessage?: boolean
} }
type MessagePage = { const hasParts = (parts: Part[] | undefined, want: Part[]) => {
session: Message[] if (!parts) return want.length === 0
part: { id: string; part: Part[] }[] return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
cursor?: string
complete: boolean
} }
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries. function mergeOptimisticPage(
type MessageLoadState = { page: { session: Message[]; part: { id: string; part: Part[] }[]; cursor?: string; complete: boolean },
touchedMessages: Set<string> items: OptimisticItem[],
removedMessages: Set<string> ) {
retainedMessages: Set<string> if (items.length === 0) return { ...page, confirmed: [] as string[] }
touchedParts: Map<string, Set<string>>
deltaParts: Map<string, Set<string>>
carriedDeltaParts: Map<string, Set<string>>
removedParts: Map<string, Set<string>>
optimisticParts: Map<string, Set<string>>
orphanParents: Set<string>
clearedMessageParts: Set<string>
}
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
const session = [...page.session] const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, item.part])) const part = new Map(page.part.map((item) => [item.id, item.part]))
const observed: { messageID: string; parts: Part[] }[] = [] const confirmed: string[] = []
for (const item of items) { for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id) const result = Binary.search(session, item.message.id, (message) => message.id)
if (!result.found) session.splice(result.index, 0, item.message) if (!result.found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id) const current = part.get(item.message.id)
const confirmed = result.found if (result.found && hasParts(current, item.parts)) {
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found) confirmed.push(item.message.id)
: [] continue
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed }) }
part.set( part.set(item.message.id, merge(current ?? [], item.parts))
item.message.id,
merge(
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
item.parts.filter((part) => !confirmed.includes(part)),
),
)
} }
return { return {
...page, ...page,
session, session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })), part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
observed, confirmed,
} }
} }
@@ -98,38 +75,7 @@ function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
return [...items.values()].sort((x, y) => cmp(x.id, y.id)) return [...items.values()].sort((x, y) => cmp(x.id, y.id))
} }
function reconcileFetched<T extends { id: string }>( export function createServerSession(client: OpencodeClient) {
fetched: T[],
current: readonly T[],
options: {
touched?: ReadonlySet<string>
retained?: ReadonlySet<string>
preserveUnfetched?: boolean | ((item: T) => boolean)
} = {},
) {
const result = new Map(fetched.map((item) => [item.id, item]))
const live = new Map(current.map((item) => [item.id, item]))
if (options.preserveUnfetched) {
for (const item of current) {
if (!result.has(item.id) && (options.preserveUnfetched === true || options.preserveUnfetched(item)))
result.set(item.id, item)
}
}
for (const id of options.retained ?? emptyIDs) {
if (result.has(id)) continue
const item = live.get(id)
if (item) result.set(id, item)
}
// Events observed while the request is pending are the freshest client state for those identities.
for (const id of options.touched ?? emptyIDs) {
const item = live.get(id)
if (item) result.set(id, item)
if (!item) result.delete(id)
}
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
}
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
const [data, setData] = createStore({ const [data, setData] = createStore({
info: {} as Record<string, Session | undefined>, info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>, session_status: {} as Record<string, SessionStatus>,
@@ -149,32 +95,10 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const inflightDiff = new Map<string, Promise<void>>() const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>() const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>() const optimistic = new Map<string, Map<string, OptimisticItem>>()
const messageLoads = new Map<string, MessageLoadState>()
const pendingParts = new Map<string, Map<string, Set<string>>>()
const orphanParts = new Map<string, Set<string>>()
const removedMessages = new Map<string, Set<string>>()
const deltaBases = new Map<string, { base: string; sessionID: string }>()
const deleteMessageParts = (
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
messageID: string,
) => {
for (const part of cache.part[messageID] ?? []) {
delete cache.part_text_accum_delta[part.id]
deltaBases.delete(part.id)
}
delete cache.part[messageID]
}
const seen = new Set<string>() const seen = new Set<string>()
const infoSeen = new Set<string>() const infoSeen = new Set<string>()
const pinned = new Map<string, number>() const pinned = new Map<string, number>()
const generations = new Map<string, object>() const generations = new Map<string, number>()
const generation = (sessionID: string) => {
const current = generations.get(sessionID)
if (current) return current
const created = {}
generations.set(sessionID, created)
return created
}
const [meta, setMeta] = createStore({ const [meta, setMeta] = createStore({
limit: {} as Record<string, number | undefined>, limit: {} as Record<string, number | undefined>,
cursor: {} as Record<string, string | undefined>, cursor: {} as Record<string, string | undefined>,
@@ -191,11 +115,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const preserve = new Set([ const preserve = new Set([
...pinned.keys(), ...pinned.keys(),
...requests.keys(), ...requests.keys(),
...inflight.keys(),
...inflightDiff.keys(),
...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(),
...Object.entries(data.permission) ...Object.entries(data.permission)
.filter(([, items]) => items.length > 0) .filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID), .map(([sessionID]) => sessionID),
@@ -219,7 +138,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
if (!preserve.has(sessionID)) stale.push(sessionID) if (!preserve.has(sessionID)) stale.push(sessionID)
} }
stale.forEach((sessionID) => infoSeen.delete(sessionID)) stale.forEach((sessionID) => infoSeen.delete(sessionID))
stale.forEach((sessionID) => generations.delete(sessionID))
setData( setData(
"info", "info",
produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])), produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])),
@@ -233,27 +151,21 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
if (cached && !options?.force) return Promise.resolve(cached) if (cached && !options?.force) return Promise.resolve(cached)
const pending = requests.get(sessionID) const pending = requests.get(sessionID)
if (pending) return pending if (pending) return pending
const active = generation(sessionID) const generation = generations.get(sessionID) ?? 0
const request = client.session.get({ sessionID }).then((result) => { const request = client.session.get({ sessionID }).then((result) => {
if (!result.data) throw new Error(`Session not found: ${sessionID}`) if (!result.data) throw new Error(`Session not found: ${sessionID}`)
if (generations.get(sessionID) !== active) return result.data if ((generations.get(sessionID) ?? 0) !== generation) return result.data
return remember(result.data) return remember(result.data)
}) })
requests.set(sessionID, request) requests.set(sessionID, request)
const cleanup = () => { void request.then(
if (requests.get(sessionID) === request) requests.delete(sessionID) () => {
if ( if (requests.get(sessionID) === request) requests.delete(sessionID)
generations.get(sessionID) === active && },
!data.info[sessionID] && () => {
!requests.has(sessionID) && if (requests.get(sessionID) === request) requests.delete(sessionID)
!messageLoads.has(sessionID) && },
!inflight.has(sessionID) && )
!inflightDiff.has(sessionID) &&
!inflightTodo.has(sessionID)
)
generations.delete(sessionID)
}
void request.then(cleanup, cleanup)
return request return request
} }
@@ -283,121 +195,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
if (items.size === 0) optimistic.delete(sessionID) if (items.size === 0) optimistic.delete(sessionID)
} }
const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const parts = item.parts.filter((part) => part.id !== partID)
const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID)
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
}
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const parts = item.parts.filter((value) => value.id !== part.id)
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, {
...item,
parts,
confirmedParts: merge(item.confirmedParts ?? [], [part]),
confirmedMessage: true,
})
}
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const confirmed = new Set(confirmedParts.map((part) => part.id))
const parts = item.parts.filter((part) => !confirmed.has(part.id))
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, {
...item,
parts,
confirmedParts: merge(item.confirmedParts ?? [], confirmedParts),
confirmedMessage: true,
})
}
const trackPartChange = (sessionID: string, messageID: string, partID: string) => {
const load = messageLoads.get(sessionID)
if (!load) return
// A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata.
const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
const parts = load.touchedParts.get(messageID)
if (parts) {
parts.add(partID)
return
}
load.touchedParts.set(messageID, new Set([partID]))
}
const resetMessageLoad = (sessionID: string, load: MessageLoadState) => {
load.touchedMessages.clear()
load.retainedMessages.clear()
load.touchedParts.clear()
load.carriedDeltaParts.clear()
load.clearedMessageParts.clear()
for (const messageID of load.removedMessages) {
load.touchedMessages.add(messageID)
load.clearedMessageParts.add(messageID)
}
for (const [messageID, parts] of load.deltaParts) {
load.touchedParts.set(messageID, new Set(parts))
load.carriedDeltaParts.set(messageID, new Set(parts))
const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
}
for (const [messageID, parts] of load.removedParts) {
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched)
const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
}
for (const [messageID, parts] of load.optimisticParts) {
load.removedMessages.delete(messageID)
load.clearedMessageParts.add(messageID)
load.touchedMessages.add(messageID)
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched)
}
}
const evict = (sessionIDs: string[]) => { const evict = (sessionIDs: string[]) => {
if (sessionIDs.length === 0) return if (sessionIDs.length === 0) return
const evicted = new Set(sessionIDs)
for (const [partID, item] of deltaBases) {
if (evicted.has(item.sessionID)) deltaBases.delete(partID)
}
sessionIDs.forEach((sessionID) => { sessionIDs.forEach((sessionID) => {
generations.delete(sessionID) generations.set(sessionID, (generations.get(sessionID) ?? 0) + 1)
clearOptimistic(sessionID) clearOptimistic(sessionID)
requests.delete(sessionID) requests.delete(sessionID)
inflight.delete(sessionID) inflight.delete(sessionID)
inflightDiff.delete(sessionID) inflightDiff.delete(sessionID)
inflightTodo.delete(sessionID) inflightTodo.delete(sessionID)
messageLoads.delete(sessionID)
pendingParts.delete(sessionID)
orphanParts.delete(sessionID)
removedMessages.delete(sessionID)
}) })
setData( setData(
produce((draft) => { produce((draft) => {
@@ -424,7 +230,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
...inflight.keys(), ...inflight.keys(),
...inflightDiff.keys(), ...inflightDiff.keys(),
...inflightTodo.keys(), ...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(), ...optimistic.keys(),
...Object.entries(data.permission) ...Object.entries(data.permission)
.filter(([, items]) => items.length > 0) .filter(([, items]) => items.length > 0)
@@ -442,11 +247,8 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }), pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
) )
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { const fetchMessages = async (sessionID: string, limit: number, before?: string) => {
const response = await (options?.retry ?? retry)(() => { const response = await retry(() => client.session.messages({ sessionID, limit, before }))
onAttempt?.()
return client.session.messages({ sessionID, limit, before })
})
const items = (response.data ?? []).filter((item) => !!item?.info?.id) const items = (response.data ?? []).filter((item) => !!item?.info?.id)
return { return {
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)), session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
@@ -459,153 +261,30 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
} }
} }
const replaceMessages = (sessionID: string, messages: Message[]) => {
const messageIDs = new Set(messages.map((message) => message.id))
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
setData("message", sessionID, reconcile(messages, { key: "id" }))
setData(
produce((draft) => {
for (const message of dropped) deleteMessageParts(draft, message.id)
}),
)
return messageIDs
}
const replaceParts = (
sessionID: string,
items: MessagePage["part"],
messageIDs: Set<string>,
load?: MessageLoadState,
) => {
for (const item of items) {
if (!messageIDs.has(item.id)) continue
const fetched = load?.clearedMessageParts.has(item.id)
? []
: item.part.filter((part) => !SKIP_PARTS.has(part.type))
const fetchedIDs = new Set(fetched.map((part) => part.id))
const pending = pendingParts.get(sessionID)?.get(item.id)
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
for (const part of fetched) {
const accumulated = data.part_text_accum_delta[part.id]
const base = deltaBases.get(part.id)?.base
const preserveDelta =
base !== undefined &&
accumulated !== undefined &&
"text" in part &&
typeof part.text === "string" &&
part.text.startsWith(base) &&
accumulated.startsWith(part.text) &&
accumulated !== part.text
if (preserveDelta) touched.add(part.id)
if (load?.carriedDeltaParts.get(item.id)?.has(part.id) && !preserveDelta) touched.delete(part.id)
}
for (const partID of load?.carriedDeltaParts.get(item.id) ?? []) {
if (!fetchedIDs.has(partID)) touched.delete(partID)
}
const parts = reconcileFetched(fetched, data.part[item.id] ?? [], { touched })
if (!parts.length) {
orphanParts.get(sessionID)?.delete(item.id)
setData(produce((draft) => deleteMessageParts(draft, item.id)))
continue
}
const partIDs = new Set(parts.map((part) => part.id))
setData(
"part_text_accum_delta",
produce((draft) => {
for (const part of data.part[item.id] ?? []) {
if (!partIDs.has(part.id) || !touched.has(part.id)) {
delete draft[part.id]
deltaBases.delete(part.id)
}
}
}),
)
setData("part", item.id, reconcile(parts, { key: "id" }))
orphanParts.get(sessionID)?.delete(item.id)
}
}
const applyMessagePage = (
sessionID: string,
page: MessagePage,
load: MessageLoadState | undefined,
preserveUnfetched: boolean | ((message: Message) => boolean),
cleanupOrphans: boolean,
) => {
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
merged.observed.forEach((item) => {
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
})
const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])])
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
touched: touchedMessages,
retained: load?.retainedMessages,
preserveUnfetched,
})
batch(() => {
const messageIDs = replaceMessages(sessionID, messages)
replaceParts(sessionID, merged.part, messageIDs, load)
const orphans = orphanParts.get(sessionID)
if (cleanupOrphans && page.complete && orphans) {
for (const messageID of orphans) {
if (!messageIDs.has(messageID)) setData(produce((draft) => deleteMessageParts(draft, messageID)))
}
orphanParts.delete(sessionID)
}
setMeta("limit", sessionID, messages.length)
setMeta("cursor", sessionID, merged.cursor)
setMeta("complete", sessionID, merged.complete)
setMeta("at", sessionID, Date.now())
})
}
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => { const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
if (meta.loading[sessionID]) return if (meta.loading[sessionID]) return
const active = generation(sessionID) const generation = generations.get(sessionID) ?? 0
const load: MessageLoadState = {
touchedMessages: new Set(),
removedMessages: new Set(),
retainedMessages: new Set(),
touchedParts: new Map(),
deltaParts: new Map(),
carriedDeltaParts: new Map(),
removedParts: new Map(),
optimisticParts: new Map(),
orphanParents: new Set(),
clearedMessageParts: new Set(),
}
messageLoads.set(sessionID, load)
setMeta("loading", sessionID, true) setMeta("loading", sessionID, true)
let applied = false await fetchMessages(sessionID, limit, before)
await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
.then((page) => { .then((page) => {
if (generations.get(sessionID) !== active) return if ((generations.get(sessionID) ?? 0) !== generation) return
const first = page.session.reduce<Message | undefined>( const next = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest), next.confirmed.forEach((messageID) => clearOptimistic(sessionID, messageID))
undefined, const messages = mode === "prepend" ? merge(data.message[sessionID] ?? [], next.session) : next.session
) batch(() => {
const preserveUnfetched = setData("message", sessionID, reconcile(messages, { key: "id" }))
mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0))) for (const item of next.part) {
applyMessagePage( const parts = item.part.filter((part) => !SKIP_PARTS.has(part.type))
sessionID, if (parts.length) setData("part", item.id, reconcile(parts, { key: "id" }))
page, }
messageLoads.get(sessionID) === load ? load : undefined, setMeta("limit", sessionID, messages.length)
preserveUnfetched, setMeta("cursor", sessionID, next.cursor)
mode !== "prepend", setMeta("complete", sessionID, next.complete)
) setMeta("at", sessionID, Date.now())
applied = true })
}) })
.finally(() => { .finally(() => {
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) { if ((generations.get(sessionID) ?? 0) === generation) setMeta("loading", sessionID, false)
for (const messageID of load.orphanParents) {
if (!orphanParts.get(sessionID)?.has(messageID)) continue
setData(produce((draft) => deleteMessageParts(draft, messageID)))
orphanParts.get(sessionID)?.delete(messageID)
}
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
}
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
}) })
} }
@@ -660,13 +339,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const eventID = eventSessionID(event) const eventID = eventSessionID(event)
if (eventID) { if (eventID) {
touch(eventID) touch(eventID)
if ( if (!data.info[eventID]) void resolve(eventID).catch(() => {})
!data.info[eventID] &&
event.type !== "session.created" &&
event.type !== "session.updated" &&
event.type !== "session.deleted"
)
void resolve(eventID).catch(() => {})
} }
switch (event.type) { switch (event.type) {
case "session.created": case "session.created":
@@ -705,21 +378,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
} }
case "message.updated": { case "message.updated": {
const info = cleanMessage((event.properties as { info: Message }).info) const info = cleanMessage((event.properties as { info: Message }).info)
const load = messageLoads.get(info.sessionID)
load?.touchedMessages.add(info.id)
load?.removedMessages.delete(info.id)
const items = optimistic.get(info.sessionID)
const item = items?.get(info.id)
if (items && item) {
if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id)
if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true })
}
const orphans = orphanParts.get(info.sessionID)
orphans?.delete(info.id)
if (orphans?.size === 0) orphanParts.delete(info.sessionID)
const removedMessagesForSession = removedMessages.get(info.sessionID)
removedMessagesForSession?.delete(info.id)
if (removedMessagesForSession?.size === 0) removedMessages.delete(info.sessionID)
const messages = data.message[info.sessionID] const messages = data.message[info.sessionID]
if (!messages) { if (!messages) {
setData("message", info.sessionID, [info]) setData("message", info.sessionID, [info])
@@ -737,20 +395,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
} }
case "message.removed": { case "message.removed": {
const props = event.properties as { sessionID: string; messageID: string } const props = event.properties as { sessionID: string; messageID: string }
const load = messageLoads.get(props.sessionID)
load?.touchedMessages.add(props.messageID)
load?.removedMessages.add(props.messageID)
load?.clearedMessageParts.add(props.messageID)
load?.deltaParts.delete(props.messageID)
load?.carriedDeltaParts.delete(props.messageID)
load?.removedParts.delete(props.messageID)
load?.optimisticParts.delete(props.messageID)
pendingParts.get(props.sessionID)?.delete(props.messageID)
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
removedMessagesForSession.add(props.messageID)
removedMessages.set(props.sessionID, removedMessagesForSession)
clearOptimistic(props.sessionID, props.messageID)
setData( setData(
produce((draft) => { produce((draft) => {
const messages = draft.message[props.sessionID] const messages = draft.message[props.sessionID]
@@ -758,7 +402,8 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const result = Binary.search(messages, props.messageID, (message) => message.id) const result = Binary.search(messages, props.messageID, (message) => message.id)
if (result.found) messages.splice(result.index, 1) if (result.found) messages.splice(result.index, 1)
} }
deleteMessageParts(draft, props.messageID) for (const part of draft.part[props.messageID] ?? []) delete draft.part_text_accum_delta[part.id]
delete draft.part[props.messageID]
}), }),
) )
return return
@@ -766,42 +411,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
case "message.part.updated": { case "message.part.updated": {
const part = (event.properties as { part: Part }).part const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) return if (SKIP_PARTS.has(part.type)) return
const messages = data.message[part.sessionID]
const load = messageLoads.get(part.sessionID)
const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found
// Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan.
if (
missing &&
(!load ||
load.clearedMessageParts.has(part.messageID) ||
removedMessages.get(part.sessionID)?.has(part.messageID))
)
return
if (missing) {
const orphans = orphanParts.get(part.sessionID) ?? new Set<string>()
orphans.add(part.messageID)
orphanParts.set(part.sessionID, orphans)
load?.orphanParents.add(part.messageID)
}
const deltas = load?.deltaParts.get(part.messageID)
deltas?.delete(part.id)
if (deltas?.size === 0) load?.deltaParts.delete(part.messageID)
const carried = load?.carriedDeltaParts.get(part.messageID)
carried?.delete(part.id)
if (carried?.size === 0) load?.carriedDeltaParts.delete(part.messageID)
const removed = load?.removedParts.get(part.messageID)
removed?.delete(part.id)
if (removed?.size === 0) load?.removedParts.delete(part.messageID)
const pending = pendingParts.get(part.sessionID)?.get(part.messageID)
pending?.delete(part.id)
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID)
const optimistic = load?.optimisticParts.get(part.messageID)
optimistic?.delete(part.id)
if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID)
deltaBases.delete(part.id)
trackPartChange(part.sessionID, part.messageID, part.id)
confirmOptimisticPart(part.sessionID, part.messageID, part)
setData( setData(
"part_text_accum_delta", "part_text_accum_delta",
produce((draft) => void delete draft[part.id]), produce((draft) => void delete draft[part.id]),
@@ -822,34 +431,10 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return return
} }
case "message.part.removed": { case "message.part.removed": {
const props = event.properties as { sessionID: string; messageID: string; partID: string } const props = event.properties as { messageID: string; partID: string }
// Part removal is event-only on the server, so its tombstone lasts until a later update or eviction.
const pending = pendingParts.get(props.sessionID) ?? new Map<string, Set<string>>()
const parts = pending.get(props.messageID) ?? new Set<string>()
parts.add(props.partID)
pending.set(props.messageID, parts)
pendingParts.set(props.sessionID, pending)
const deltas = messageLoads.get(props.sessionID)?.deltaParts.get(props.messageID)
deltas?.delete(props.partID)
if (deltas?.size === 0) messageLoads.get(props.sessionID)?.deltaParts.delete(props.messageID)
const load = messageLoads.get(props.sessionID)
const carried = load?.carriedDeltaParts.get(props.messageID)
carried?.delete(props.partID)
if (carried?.size === 0) load?.carriedDeltaParts.delete(props.messageID)
if (load) {
const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
parts.add(props.partID)
load.removedParts.set(props.messageID, parts)
const optimistic = load.optimisticParts.get(props.messageID)
optimistic?.delete(props.partID)
if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID)
}
trackPartChange(props.sessionID, props.messageID, props.partID)
clearOptimisticPart(props.sessionID, props.messageID, props.partID)
setData( setData(
produce((draft) => { produce((draft) => {
delete draft.part_text_accum_delta[props.partID] delete draft.part_text_accum_delta[props.partID]
deltaBases.delete(props.partID)
const parts = draft.part[props.messageID] const parts = draft.part[props.messageID]
if (!parts) return if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id) const result = Binary.search(parts, props.partID, (part) => part.id)
@@ -860,31 +445,13 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return return
} }
case "message.part.delta": { case "message.part.delta": {
const props = event.properties as { const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
sessionID: string
messageID: string
partID: string
field: string
delta: string
}
const parts = data.part[props.messageID] const parts = data.part[props.messageID]
if (!parts) return if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id) const result = Binary.search(parts, props.partID, (part) => part.id)
if (!result.found) return if (!result.found) return
trackPartChange(props.sessionID, props.messageID, props.partID)
const load = messageLoads.get(props.sessionID)
if (load) {
const parts = load.deltaParts.get(props.messageID) ?? new Set<string>()
parts.add(props.partID)
load.deltaParts.set(props.messageID, parts)
const carried = load.carriedDeltaParts.get(props.messageID)
carried?.delete(props.partID)
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
}
const field = props.field as keyof (typeof parts)[number] const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field] const current = parts[result.index]?.[field]
if (!deltaBases.has(props.partID) && typeof current === "string")
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
setData( setData(
"part_text_accum_delta", "part_text_accum_delta",
props.partID, props.partID,
@@ -904,18 +471,14 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
} }
case "permission.asked": { case "permission.asked": {
const permission = event.properties as PermissionRequest const permission = event.properties as PermissionRequest
const permissions = data.permission[permission.sessionID] const permissions = data.permission[permission.sessionID] ?? []
if (!permissions) {
setData("permission", permission.sessionID, [permission])
return
}
const result = Binary.search(permissions, permission.id, (item) => item.id) 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, result.index, reconcile(permission))
if (!result.found) if (!result.found)
setData( setData(
"permission", "permission",
permission.sessionID, permission.sessionID,
produce((draft) => void draft.splice(result.index, 0, permission)), produce((draft = []) => void draft.splice(result.index, 0, permission)),
) )
return return
} }
@@ -934,18 +497,14 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
} }
case "question.asked": { case "question.asked": {
const question = event.properties as QuestionRequest const question = event.properties as QuestionRequest
const questions = data.question[question.sessionID] const questions = data.question[question.sessionID] ?? []
if (!questions) {
setData("question", question.sessionID, [question])
return
}
const result = Binary.search(questions, question.id, (item) => item.id) 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, result.index, reconcile(question))
if (!result.found) if (!result.found)
setData( setData(
"question", "question",
question.sessionID, question.sessionID,
produce((draft) => void draft.splice(result.index, 0, question)), produce((draft = []) => void draft.splice(result.index, 0, question)),
) )
return return
} }
@@ -992,70 +551,32 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
}, },
optimistic: { optimistic: {
add(input: { sessionID: string; message: Message; parts: Part[] }) { add(input: { sessionID: string; message: Message; parts: Part[] }) {
const parts = input.parts
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
.sort((a, b) => cmp(a.id, b.id))
const load = messageLoads.get(input.sessionID)
if (load?.clearedMessageParts.has(input.message.id)) {
const touched = load.touchedParts.get(input.message.id) ?? new Set<string>()
parts.forEach((part) => touched.add(part.id))
load.touchedParts.set(input.message.id, touched)
}
if (load) {
load.removedMessages.delete(input.message.id)
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id)))
}
const items = optimistic.get(input.sessionID) const items = optimistic.get(input.sessionID)
const removedMessagesForSession = removedMessages.get(input.sessionID) if (items) items.set(input.message.id, input)
removedMessagesForSession?.delete(input.message.id) if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, input]]))
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID)
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
if (!items)
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message])) setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
setData( setData(
"part_text_accum_delta", "part",
produce((draft) => { input.message.id,
for (const part of [...(data.part[input.message.id] ?? []), ...parts]) { input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
delete draft[part.id]
deltaBases.delete(part.id)
}
}),
) )
setData("part", input.message.id, parts)
}, },
remove(input: { sessionID: string; messageID: string }) { remove(input: { sessionID: string; messageID: string }) {
const item = optimistic.get(input.sessionID)?.get(input.messageID)
if (!item) return
messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID)
clearOptimistic(input.sessionID, input.messageID) clearOptimistic(input.sessionID, input.messageID)
if (item.confirmedMessage) {
const partIDs = new Set(item.parts.map((part) => part.id))
setData(
produce((draft) => {
for (const part of item.parts) {
delete draft.part_text_accum_delta[part.id]
deltaBases.delete(part.id)
}
const parts = draft.part[input.messageID]
if (!parts) return
draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id))
if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID]
}),
)
return
}
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID)) setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
setData(produce((draft) => deleteMessageParts(draft, input.messageID))) setData(
"part",
produce((draft) => void delete draft[input.messageID]),
)
}, },
}, },
diff(sessionID: string, options?: { force?: boolean }) { diff(sessionID: string, options?: { force?: boolean }) {
touch(sessionID) touch(sessionID)
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve() if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
return runInflight(inflightDiff, sessionID, () => { return runInflight(inflightDiff, sessionID, () => {
const active = generation(sessionID) const generation = generations.get(sessionID) ?? 0
return retry(() => client.session.diff({ sessionID })).then((result) => { return retry(() => client.session.diff({ sessionID })).then((result) => {
if (generations.get(sessionID) !== active) return if ((generations.get(sessionID) ?? 0) !== generation) return
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" })) setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
}) })
}) })
@@ -1064,9 +585,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
touch(sessionID) touch(sessionID)
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve() if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
return runInflight(inflightTodo, sessionID, () => { return runInflight(inflightTodo, sessionID, () => {
const active = generation(sessionID) const generation = generations.get(sessionID) ?? 0
return retry(() => client.session.todo({ sessionID })).then((result) => { return retry(() => client.session.todo({ sessionID })).then((result) => {
if (generations.get(sessionID) !== active) return if ((generations.get(sessionID) ?? 0) !== generation) return
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" })) setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
}) })
}) })
+43 -66
View File
@@ -112,51 +112,18 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
navigate(href) navigate(href)
} }
const removeTab = (index: number) => {
const tab = store[index]
if (!tab) return
const key = tabKey(tab)
const draftID = tab.type === "draft" ? tab.draftID : undefined
const nextTab = store[index + 1] ?? store[index - 1]
closing.add(key)
void startTransition(() => {
setStore(
produce((tabs) => {
tabs.splice(index, 1)
}),
)
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
if (nextTab) navigateTab(nextTab)
else navigate("/")
}).finally(() => closing.delete(key))
memory.remove(key)
if (draftID) removeDraftPersisted(draftID)
}
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)) const existing = store.find((item) => tabKey(item) === tabKey(next))
if (existing) return existing if (existing) return existing
void startTransition(() => {
setStore(
produce((tabs) => {
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
tabs.push(next)
}),
)
})
return next
},
reorder(keys: string[]) {
setStore( setStore(
produce((tabs) => { produce((tabs) => {
const byKey = new Map(tabs.map((tab) => [tabKey(tab), tab])) if (tabs.some((item) => tabKey(item) === tabKey(next))) return
const next = keys.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab) tabs.push(next)
if (next.length !== tabs.length) return
tabs.splice(0, tabs.length, ...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)
@@ -165,29 +132,27 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}, },
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) { newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
const draftID = uuid() const draftID = uuid()
void startTransition(() => { setStore(
setStore( produce((tabs) => {
produce((tabs) => { tabs.push({ type: "draft", draftID, ...draft })
tabs.push({ type: "draft", draftID, ...draft }) }),
}), )
) navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
})
}, },
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) { updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
void startTransition(() => { setStore(
setStore( (tab) => tab.type === "draft" && tab.draftID === draftID,
(tab) => tab.type === "draft" && tab.draftID === draftID, produce((tab) => Object.assign(tab, draft)),
produce((tab) => Object.assign(tab, draft)), )
)
})
}, },
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) { promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
// Keep the replacement and navigation atomic so /new-session never renders // We're viewing this draft when /new-session?draftId=… points at it. Promoting
// after its backing draft tab has been removed from the store. // replaces the draft tab with a session tab, so the draft route would stop resolving
// and fall back home. Navigate to the new session first so we leave /new-session
// before the draft is removed from the store.
const active = location.pathname === "/new-session" && location.query.draftId === draftID const active = location.pathname === "/new-session" && location.query.draftId === draftID
const next = { type: "session" as const, ...session } const next = { type: "session" as const, ...session }
void startTransition(() => { startTransition(() => {
setStore( setStore(
produce((tabs) => { produce((tabs) => {
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID) const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
@@ -200,12 +165,25 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
memory.remove(`draft:${draftID}`) memory.remove(`draft:${draftID}`)
removeDraftPersisted(draftID) removeDraftPersisted(draftID)
}, },
removeTab, removeTab: (index: number) => {
removeSessionTab(input: Omit<SessionTab, "type">) { const tab = store[index]
const index = store.findIndex( if (!tab) return
(tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId, const key = tabKey(tab)
) const draftID = tab.type === "draft" ? tab.draftID : undefined
if (index !== -1) removeTab(index) const nextTab = store[index + 1] ?? store[index - 1]
closing.add(key)
void startTransition(() => {
setStore(
produce((tabs) => {
tabs.splice(index, 1)
}),
)
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
if (nextTab) navigateTab(nextTab)
else navigate("/")
}).finally(() => closing.delete(key))
memory.remove(key)
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] : []))
@@ -217,10 +195,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (server.key === key) navigate("/") if (server.key === key) navigate("/")
}, },
removeSessions: (input: SessionTabsRemovedDetail) => { removeSessions: (input: SessionTabsRemovedDetail) => {
const targetServer = input.server ?? server.key
const removed = store const removed = store
.filter( .filter(
(tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId), (tab) => tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId),
) )
.map(tabKey) .map(tabKey)
void startTransition(() => { void startTransition(() => {
@@ -228,28 +205,28 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
produce((tabs) => { produce((tabs) => {
const sessionIDs = new Set(input.sessionIDs) const sessionIDs = new Set(input.sessionIDs)
const currentHref = const currentHref =
targetServer === server.key && params.dir && params.id params.dir && params.id
? tabHref({ ? tabHref({
type: "session", type: "session",
server: targetServer, server: server.key,
sessionId: params.id, sessionId: params.id,
}) })
: undefined : undefined
const currentIndex = currentHref const currentIndex = currentHref
? tabs.findIndex( ? tabs.findIndex(
(tab) => tab.type === "session" && tab.server === targetServer && tabHref(tab) === currentHref, (tab) => tab.type === "session" && tab.server === server.key && tabHref(tab) === currentHref,
) )
: -1 : -1
const currentTab = tabs[currentIndex] const currentTab = tabs[currentIndex]
const removedCurrent = const removedCurrent =
currentTab?.type === "session" && currentTab?.type === "session" &&
currentTab.server === targetServer && currentTab.server === server.key &&
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 !== targetServer) continue if (tab.server !== server.key) continue
if (!sessionIDs.has(tab.sessionId)) continue if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1) tabs.splice(i, 1)
} }
@@ -1,59 +0,0 @@
import { expect, test } from "bun:test"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { selectProviderCatalog } from "./provider-catalog"
const catalog = (id: string): NormalizedProviderListResponse => ({
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
connected: [id],
default: { [id]: `${id}-model` },
})
test("selects the ready catalog for an explicit directory", () => {
const directory = catalog("directory")
expect(
selectProviderCatalog({
explicit: true,
directory: "/repo",
catalog: { ready: true, providers: directory },
}),
).toBe(directory)
})
test("returns an empty catalog while an explicit directory is unresolved", () => {
expect(selectProviderCatalog({ explicit: true })).toEqual({ all: new Map(), connected: [], default: {} })
expect(
selectProviderCatalog({
explicit: true,
directory: "/repo",
catalog: { ready: false, providers: catalog("directory") },
}),
).toEqual({ all: new Map(), connected: [], default: {} })
})
test("uses the route catalog when it is ready", () => {
const directory = catalog("directory")
expect(
selectProviderCatalog({
explicit: false,
directory: "/repo",
catalog: { ready: true, providers: directory },
global: catalog("global"),
}),
).toBe(directory)
})
test("falls back to the global catalog for route consumers", () => {
const global = catalog("global")
expect(selectProviderCatalog({ explicit: false, global })).toBe(global)
expect(
selectProviderCatalog({
explicit: false,
directory: "/repo",
catalog: { ready: false, providers: catalog("directory") },
global,
}),
).toBe(global)
})
@@ -1,27 +0,0 @@
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
type DirectoryCatalog = {
ready: boolean
providers: NormalizedProviderListResponse
}
type ProviderCatalogInput =
| {
explicit: true
directory?: string
catalog?: DirectoryCatalog
}
| {
explicit: false
directory?: string
catalog?: DirectoryCatalog
global: NormalizedProviderListResponse
}
export function selectProviderCatalog(input: ProviderCatalogInput) {
if (input.directory && input.catalog?.ready) return input.catalog.providers
if (input.explicit) return emptyProviderCatalog
return input.global
}
+8 -18
View File
@@ -2,8 +2,7 @@ import { useServerSync } from "@/context/server-sync"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { Iterable, pipe } from "effect" import { Iterable, pipe } from "effect"
import type { Accessor } from "solid-js" import { createMemo } from "solid-js"
import { selectProviderCatalog } from "./provider-catalog"
export const popularProviders = [ export const popularProviders = [
"opencode", "opencode",
@@ -17,25 +16,16 @@ export const popularProviders = [
] ]
const popularProviderSet = new Set(popularProviders) const popularProviderSet = new Set(popularProviders)
export function useProviders(directory?: Accessor<string | undefined>) { export function useProviders() {
const serverSync = useServerSync() const serverSync = useServerSync()
const params = useParams() const params = useParams()
const dir = () => (directory ? directory() : decode64(params.dir)) const dir = createMemo(() => decode64(params.dir) ?? "")
const providers = () => { const providers = () => {
const value = dir() if (dir()) {
const projectStore = value ? serverSync().child(value)[0] : undefined const [projectStore] = serverSync().child(dir())
if (directory) if (projectStore.provider_ready) return projectStore.provider
return selectProviderCatalog({ }
explicit: true, return serverSync().data.provider
directory: value,
catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
})
return selectProviderCatalog({
explicit: false,
directory: value,
catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
global: serverSync().data.provider,
})
} }
return { return {
all: () => providers().all, all: () => providers().all,
+2 -7
View File
@@ -592,11 +592,10 @@ export const dict = {
"home.server.collapse": "Collapse server projects", "home.server.collapse": "Collapse server projects",
"home.server.expand": "Expand server projects", "home.server.expand": "Expand server projects",
"home.sessions.search.placeholder": "Search sessions", "home.sessions.search.placeholder": "Search sessions",
"home.sessions.search.placeholder.scoped": "Search sessions in {{scope}}",
"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}}",
"home.sessions.empty": "Nothing here yet", "home.sessions.empty": "No sessions found",
"home.sessions.empty.description": "Create a session to get started", "home.sessions.empty.description": "Start a new session for this project",
"home.sessions.group.today": "Today", "home.sessions.group.today": "Today",
"home.sessions.group.yesterday": "Yesterday", "home.sessions.group.yesterday": "Yesterday",
"home.sessions.group.older": "Older", "home.sessions.group.older": "Older",
@@ -656,10 +655,6 @@ export const dict = {
"session.new.worktree.main": "Main branch", "session.new.worktree.main": "Main branch",
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})", "session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
"session.new.worktree.create": "Create new worktree", "session.new.worktree.create": "Create new worktree",
"session.new.workspace.runIn": "Run session in",
"session.new.workspace.triggerLocal": "Local",
"session.new.workspace.local": "Local repository",
"session.new.workspace.existing": "Workspace…",
"session.new.lastModified": "Last modified", "session.new.lastModified": "Last modified",
"session.header.search.placeholder": "Search {{project}}", "session.header.search.placeholder": "Search {{project}}",
+1 -3
View File
@@ -507,11 +507,9 @@ export const dict = {
"home.projects": "项目", "home.projects": "项目",
"home.project.add": "添加项目", "home.project.add": "添加项目",
"home.sessions.search.placeholder": "搜索会话", "home.sessions.search.placeholder": "搜索会话",
"home.sessions.search.placeholder.scoped": "在 {{scope}} 中搜索会话",
"home.sessions.search.sessions": "会话", "home.sessions.search.sessions": "会话",
"home.sessions.search.noResults": "未找到与 {{query}} 相关的会话", "home.sessions.search.noResults": "未找到与 {{query}} 相关的会话",
"home.sessions.empty": "这里还没有内容", "home.sessions.empty": "未找到会话",
"home.sessions.empty.description": "创建一个会话以开始。",
"home.sessions.group.today": "今天", "home.sessions.group.today": "今天",
"home.sessions.group.yesterday": "昨天", "home.sessions.group.yesterday": "昨天",
"home.sessions.group.older": "更早", "home.sessions.group.older": "更早",
+3 -96
View File
@@ -1,7 +1,6 @@
@import "@opencode-ai/ui/styles/tailwind"; @import "@opencode-ai/ui/styles/tailwind";
@import "@opencode-ai/session-ui/styles"; @import "@opencode-ai/session-ui/styles";
@import "@opencode-ai/ui/v2/styles/tailwind.css"; @import "@opencode-ai/ui/v2/styles/tailwind.css";
@import "tw-animate-css";
@font-face { @font-face {
font-family: "JetBrainsMono Nerd Font Mono"; font-family: "JetBrainsMono Nerd Font Mono";
@@ -108,45 +107,6 @@
} }
} }
.home-session-group-header::before {
content: "";
position: absolute;
top: -12px;
left: 0;
width: 100%;
height: 12px;
background: var(--v2-background-bg-base);
}
.home-session-group-header::after {
content: "";
position: absolute;
top: 100%;
left: 0;
width: 100%;
height: 16px;
pointer-events: none;
background: linear-gradient(
180deg,
var(--v2-background-bg-base) 0%,
color-mix(in srgb, var(--v2-background-bg-base) 92.0456%, transparent) 7.93%,
color-mix(in srgb, var(--v2-background-bg-base) 84.9947%, transparent) 14.14%,
color-mix(in srgb, var(--v2-background-bg-base) 78.6813%, transparent) 19%,
color-mix(in srgb, var(--v2-background-bg-base) 72.9394%, transparent) 22.85%,
color-mix(in srgb, var(--v2-background-bg-base) 67.6028%, transparent) 26.05%,
color-mix(in srgb, var(--v2-background-bg-base) 62.5055%, transparent) 28.95%,
color-mix(in srgb, var(--v2-background-bg-base) 57.4815%, transparent) 31.91%,
color-mix(in srgb, var(--v2-background-bg-base) 52.3647%, transparent) 35.27%,
color-mix(in srgb, var(--v2-background-bg-base) 46.989%, transparent) 39.4%,
color-mix(in srgb, var(--v2-background-bg-base) 41.1884%, transparent) 44.65%,
color-mix(in srgb, var(--v2-background-bg-base) 34.7969%, transparent) 51.36%,
color-mix(in srgb, var(--v2-background-bg-base) 27.6484%, transparent) 59.9%,
color-mix(in srgb, var(--v2-background-bg-base) 19.5767%, transparent) 70.62%,
color-mix(in srgb, var(--v2-background-bg-base) 10.416%, transparent) 83.87%,
transparent 100%
);
}
[data-slot="titlebar-update-loader"] { [data-slot="titlebar-update-loader"] {
display: block; display: block;
flex-shrink: 0; flex-shrink: 0;
@@ -172,65 +132,12 @@
} }
} }
@keyframes home-projects-fade-top { @keyframes fade-in {
from { from {
visibility: hidden; opacity: 0;
} }
to { to {
visibility: visible; opacity: 1;
}
}
@keyframes home-projects-fade-bottom {
from {
visibility: visible;
}
to {
visibility: hidden;
}
}
[data-slot="home-projects-scroll"] {
timeline-scope: --home-projects-scroll;
}
[data-slot="home-projects-scroll"]::before,
[data-slot="home-projects-scroll"]::after {
content: "";
position: absolute;
left: 0;
right: 0;
z-index: 10;
height: 16px;
pointer-events: none;
visibility: hidden;
}
[data-slot="home-projects-scroll"]::before {
top: 0;
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
}
[data-slot="home-projects-scroll"]::after {
bottom: 0;
background: linear-gradient(to top, var(--v2-background-bg-base), transparent);
}
@supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) {
[data-slot="home-projects-scroll"] .scroll-view__viewport {
scroll-timeline: --home-projects-scroll y;
}
[data-slot="home-projects-scroll"]::before {
animation: home-projects-fade-top linear both;
animation-timeline: --home-projects-scroll;
animation-range: 0 0.1px;
}
[data-slot="home-projects-scroll"]::after {
animation: home-projects-fade-bottom linear both;
animation-timeline: --home-projects-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
} }
} }
} }
+8 -12
View File
@@ -58,18 +58,14 @@ export function DirectoryDataProvider(
}) })
return ( return (
<Show when={directory()} keyed> <DataProvider
{(directory) => ( data={sync().data}
<DataProvider directory={directory()}
data={sync().data} onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
directory={directory} onSessionHref={href}
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))} >
onSessionHref={href} <LocalProvider>{props.children}</LocalProvider>
> </DataProvider>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)}
</Show>
) )
} }
@@ -1,51 +0,0 @@
import { expect, test } from "bun:test"
import { SESSION_TABS_REMOVED_EVENT, readSessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { archiveHomeSession } from "./home-session-archive"
import type { ServerConnection } from "@/context/server"
const remote = "remote" as ServerConnection.Key
test("archiving a Home session removes its open titlebar tab", async () => {
let detail: ReturnType<typeof readSessionTabsRemovedDetail>
let removed = false
window.addEventListener(
SESSION_TABS_REMOVED_EVENT,
(event) => {
detail = readSessionTabsRemovedDetail(event)
},
{ once: true },
)
await archiveHomeSession({
server: remote,
session: { id: "ses_1", directory: "/workspace" },
update: async () => undefined,
remove: () => {
removed = true
},
})
expect(removed).toBe(true)
expect(detail).toEqual({ server: remote, directory: "/workspace", sessionIDs: ["ses_1"] })
})
test("reports archive failures without removing the session", async () => {
const failure = new Error("offline")
let error: unknown
let removed = false
await archiveHomeSession({
server: remote,
session: { id: "ses_1", directory: "/workspace" },
update: async () => Promise.reject(failure),
remove: () => {
removed = true
},
onError: (value) => {
error = value
},
})
expect(error).toBe(failure)
expect(removed).toBe(false)
})
@@ -1,37 +0,0 @@
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import type { ServerConnection } from "@/context/server"
type HomeSession = {
id: string
directory: string
}
type SessionUpdate = {
directory: string
sessionID: string
time: { archived: number }
}
export async function archiveHomeSession(input: {
server: ServerConnection.Key
session: HomeSession
update: (value: SessionUpdate) => Promise<unknown>
remove: () => void
onError?: (error: unknown) => void
}) {
await input
.update({
directory: input.session.directory,
sessionID: input.session.id,
time: { archived: Date.now() },
})
.then(() => {
input.remove()
notifySessionTabsRemoved({
server: input.server,
directory: input.session.directory,
sessionIDs: [input.session.id],
})
})
.catch((error) => input.onError?.(error))
}
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -1,18 +1,26 @@
import { createEffect, Suspense, type ParentProps } from "solid-js" import { createEffect, Suspense, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar" import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button" import { HelpButton } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useNotification } from "@/context/notification"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click" import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast" import { setV2Toast, ToastRegion } from "@/utils/toast"
export default function NewLayout(props: ParentProps) { export default function NewLayout(props: ParentProps) {
const platform = usePlatform() const platform = usePlatform()
const notification = useNotification()
const navigate = useNavigate() const navigate = useNavigate()
const params = useParams<{ id?: string }>()
setNavigate(navigate) setNavigate(navigate)
createEffect(() => setV2Toast(true)) createEffect(() => setV2Toast(true))
createEffect(() => {
if (!notification.ready() || !params.id) return
if (notification.session.unseenCount(params.id) === 0) return
notification.session.markViewed(params.id)
})
const update: TitlebarUpdate = { const update: TitlebarUpdate = {
version: () => { version: () => {
@@ -36,7 +44,7 @@ export default function NewLayout(props: ParentProps) {
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict"> <main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Suspense>{props.children}</Suspense> <Suspense>{props.children}</Suspense>
</main> </main>
{import.meta.env.DEV && <DebugBar inline />} {import.meta.env.DEV && <DebugBar />}
<HelpButton /> <HelpButton />
<ToastRegion v2 /> <ToastRegion v2 />
</div> </div>
+1 -2
View File
@@ -20,7 +20,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
@@ -2116,6 +2115,7 @@ export default function LegacyLayout(props: ParentProps) {
<div class="shrink-0 py-4"> <div class="shrink-0 py-4">
<Button <Button
size="large" size="large"
icon="new-session"
class="w-full" class="w-full"
onClick={() => { onClick={() => {
const dir = worktree() const dir = worktree()
@@ -2123,7 +2123,6 @@ export default function LegacyLayout(props: ParentProps) {
navigateWithSidebarReset(`/${base64Encode(dir)}/session`) navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
}} }}
> >
<IconV2 name="edit" size="small" />
{language.t("command.session.new")} {language.t("command.session.new")}
</Button> </Button>
</div> </div>
+2 -1
View File
@@ -2,7 +2,6 @@ import { getFilename } from "@opencode-ai/core/util/path"
import { type Session } from "@opencode-ai/sdk/v2/client" import { type Session } from "@opencode-ai/sdk/v2/client"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import type { ServerConnection } from "@/context/server" import type { ServerConnection } from "@/context/server"
import type { HomeProjectSelection } from "@/context/layout"
type SessionStore = { type SessionStore = {
session?: Session[] session?: Session[]
@@ -57,6 +56,8 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri
export const displayName = (project: { name?: string; worktree: string }) => export const displayName = (project: { name?: string; worktree: string }) =>
project.name || getFilename(project.worktree) || project.worktree project.name || getFilename(project.worktree) || project.worktree
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export function toggleHomeProjectSelection( export function toggleHomeProjectSelection(
current: HomeProjectSelection | undefined, current: HomeProjectSelection | undefined,
server: ServerConnection.Key, server: ServerConnection.Key,
@@ -11,29 +11,23 @@ export function SessionTabAvatar(props: {
directory: string directory: string
sessionId: string sessionId: string
activeServer: boolean activeServer: boolean
revealProjectOnHover?: boolean
}) { }) {
const directory = () => props.directory const directory = () => props.directory
const sessionId = () => props.sessionId const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer) const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
const projectAvatar = () => (
<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()}
/>
)
return ( return (
<Show when={state.loading()} fallback={projectAvatar()}> <Show
<span class="relative block size-4 shrink-0"> when={state.loading()}
<SessionProgressIndicatorV2 fallback={
class={`absolute inset-0 ${props.revealProjectOnHover === false ? "" : "group-hover:invisible"}`} <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()}
/> />
<Show when={props.revealProjectOnHover !== false}> }
<span class="invisible absolute inset-0 group-hover:visible">{projectAvatar()}</span> >
</Show> <SessionProgressIndicatorV2 class="shrink-0" />
</span>
</Show> </Show>
) )
} }
@@ -1,7 +1,6 @@
import type { Session } from "@opencode-ai/sdk/v2/client" import type { Session } from "@opencode-ai/sdk/v2/client"
import { Avatar } from "@opencode-ai/ui/avatar" import { Avatar } from "@opencode-ai/ui/avatar"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
@@ -301,7 +300,7 @@ export const NewSessionItem = (props: {
}} }}
> >
<div class="shrink-0 size-6 flex items-center justify-center"> <div class="shrink-0 size-6 flex items-center justify-center">
<IconV2 name="edit" size="small" class="text-icon-weak" /> <Icon name="new-session" size="small" class="text-icon-weak" />
</div> </div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span> <span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
</A> </A>
@@ -10,8 +10,6 @@ import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { type Session } from "@opencode-ai/sdk/v2/client" import { type Session } from "@opencode-ai/sdk/v2/client"
@@ -216,10 +214,9 @@ const WorkspaceActions = (props: {
</DropdownMenu> </DropdownMenu>
<Show when={!props.touch()}> <Show when={!props.touch()}>
<Tooltip value={props.language.t("command.session.new")} placement="top"> <Tooltip value={props.language.t("command.session.new")} placement="top">
<IconButtonV2 <IconButton
icon={<IconV2 name="edit" size="small" />} icon="new-session"
variant="ghost" variant="ghost"
size="small"
class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto" class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
data-action="workspace-new-session" data-action="workspace-new-session"
data-workspace={base64Encode(props.directory)} data-workspace={base64Encode(props.directory)}
+23 -109
View File
@@ -1,27 +1,12 @@
import { Show, createEffect, createMemo, createResource, untrack } from "solid-js" import { createEffect, createMemo, onMount, untrack } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useSearchParams } from "@solidjs/router" import { useSearchParams } from "@solidjs/router"
import { NewSessionDesignView } from "@/components/session" import { NewSessionDesignView } from "@/components/session"
import { PromptInput } from "@/components/prompt-input"
import { useSettingsCommand } from "@/components/settings-dialog"
import {
PromptProjectAddButton,
PromptProjectSelector,
createPromptProjectController,
} from "@/components/prompt-project-selector"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useServerSync } from "@/context/server-sync" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { useLanguage } from "@/context/language"
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
import { useSessionKey } from "@/pages/session/session-layout"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
const showWorkspaceBar = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
/** /**
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt * The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
@@ -32,43 +17,23 @@ export default function NewSessionPage() {
const prompt = usePrompt() const prompt = usePrompt()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const serverSync = useServerSync()
const comments = useComments() const comments = useComments()
const language = useLanguage() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const route = useSessionKey()
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
useComposerCommands()
useSettingsCommand()
let inputRef: HTMLDivElement | undefined let inputRef: HTMLDivElement | undefined
const inputController = createPromptInputController({ const composer = createSessionComposerState()
sessionKey: route.sessionKey,
sessionID: () => route.params.id,
queryOptions: serverSync().queryOptions,
})
const projectControls = createPromptProjectControls()
const projectController = createPromptProjectController({
controls: projectControls,
onDone: () => inputRef?.focus(),
})
const [store, setStore] = createStore<{ worktree?: string }>({}) const [store, setStore] = createStore({
worktree: "main",
})
const newSessionWorktree = createMemo(() => { const newSessionWorktree = createMemo(() => {
if (store.worktree) return store.worktree if (store.worktree === "create") return "create"
const project = sync().project const project = sync().project
if (project && sdk().directory !== project.worktree) return sdk().directory if (project && sdk().directory !== project.worktree) return sdk().directory
return "main" return "main"
}) })
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
const selectedBranch = createMemo(() => {
const worktree = newSessionWorktree()
if (worktree === "main" || worktree === "create") return localBranch()
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
})
createEffect(() => { createEffect(() => {
if (!prompt.ready()) return if (!prompt.ready()) return
@@ -80,15 +45,9 @@ export default function NewSessionPage() {
}) })
}) })
createEffect(() => { onMount(() => {
if (!prompt.ready()) return
requestAnimationFrame(() => inputRef?.focus()) requestAnimationFrame(() => inputRef?.focus())
}) })
const ready = Promise.resolve()
const [promptReady] = createResource(
() => prompt.ready.promise ?? ready,
(promise) => promise.then(() => true),
)
return ( return (
<div class="relative size-full overflow-hidden flex flex-col"> <div class="relative size-full overflow-hidden flex flex-col">
@@ -96,65 +55,20 @@ export default function NewSessionPage() {
<div class="@container relative flex flex-col min-h-0 h-full bg-background-stronger flex-1"> <div class="@container relative flex flex-col min-h-0 h-full bg-background-stronger flex-1">
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]"> <div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
<NewSessionDesignView> <NewSessionDesignView>
<div class={NEW_SESSION_CONTENT_WIDTH}> <SessionComposerRegion
<Show state={composer}
when={prompt.ready() || promptReady()} ready
fallback={ centered={false}
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak pointer-events-none"> placement="inline"
{language.t("prompt.loading")} inputRef={(el) => {
</div> inputRef = el
} }}
> newSessionWorktree={newSessionWorktree()}
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar, "gap-3": !showWorkspaceBar }}> onNewSessionWorktreeReset={() => setStore("worktree", "main")}
<PromptInput onSubmit={() => comments.clear()}
controls={inputController()} onResponseSubmit={() => {}}
variant="new-session" setPromptDockRef={() => {}}
ref={(el) => { />
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("worktree", undefined)}
onSubmit={() => comments.clear()}
toolbar={
<Show when={!projectController.selected()}>
<PromptProjectAddButton controller={projectController} />
</Show>
}
/>
<Show when={projectController.selected()}>
<div
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
classList={{
"flex-col justify-center sm:flex-row": showWorkspaceBar,
"justify-start": !showWorkspaceBar,
}}
>
<PromptProjectSelector
controller={projectController}
placement={showWorkspaceBar ? "bottom" : "bottom-start"}
/>
<Show when={showWorkspaceBar}>
<PromptWorkspaceSelector
value={newSessionWorktree()}
projectRoot={projectRoot()}
workspaces={sync().project?.sandboxes ?? []}
branch={selectedBranch()}
onChange={(value) =>
setStore(
"worktree",
value === "main" && sync().project?.worktree !== sdk().directory
? sync().project?.worktree
: value,
)
}
onDone={() => inputRef?.focus()}
/>
</Show>
</div>
</Show>
</div>
</Show>
</div>
</NewSessionDesignView> </NewSessionDesignView>
</div> </div>
</div> </div>
+122 -191
View File
@@ -29,7 +29,7 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useSearchParams } from "@solidjs/router" import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session" import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
@@ -42,15 +42,8 @@ import { useServerSDK } from "@/context/server-sdk"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal" import { useTerminal } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input"
import { useSettingsCommand } from "@/components/settings-dialog"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import { import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
createPromptInputController,
createSessionComposerController,
createSessionComposerRegionController,
SessionComposerRegion,
} from "@/pages/session/composer"
import { import {
createOpenReviewFile, createOpenReviewFile,
createSessionTabs, createSessionTabs,
@@ -66,7 +59,6 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { syncSessionModel } from "@/pages/session/session-model-helpers" import { syncSessionModel } from "@/pages/session/session-model-helpers"
import { SessionSidePanel } from "@/pages/session/session-side-panel" import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel" import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
@@ -74,9 +66,7 @@ import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
import { createSessionOwnership } from "./session/session-ownership"
type FollowupItem = FollowupDraft & { id: string } type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context"> type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
@@ -85,35 +75,6 @@ const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "turn" type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch" type VcsMode = "git" | "branch"
const sessionViewState = () => ({
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes",
changes: "git" as ChangeMode,
})
async function runPromptRollbackMutation<T, R>(input: {
capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void }
optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void
request: () => Promise<R>
complete: (result: R) => void
rollback: () => void
fail: (error: unknown) => void
}) {
const prompt = input.capturePrompt()
const previous = prompt.current().slice()
batch(() => input.optimistic(prompt))
await input
.request()
.then(input.complete)
.catch((error) => {
batch(() => {
input.rollback()
prompt.set(previous)
})
input.fail(error)
})
}
export default function Page() { export default function Page() {
const serverSync = useServerSync() const serverSync = useServerSync()
const layout = useLayout() const layout = useLayout()
@@ -132,9 +93,7 @@ export default function Page() {
const terminal = useTerminal() const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation() const location = useLocation()
const navigate = useNavigate()
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns()) const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
createEffect(() => { createEffect(() => {
@@ -159,12 +118,7 @@ export default function Page() {
}, },
}) })
const composer = createSessionComposerController() const composer = createSessionComposerState()
const inputController = createPromptInputController({
sessionKey,
sessionID: () => params.id,
queryOptions: serverSync().queryOptions,
})
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey)) const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
@@ -302,7 +256,9 @@ export default function Page() {
) )
const [store, setStore] = createStore({ const [store, setStore] = createStore({
...sessionViewState(), messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes",
changes: "git" as ChangeMode,
newSessionWorktree: "main", newSessionWorktree: "main",
deferRender: false, deferRender: false,
}) })
@@ -326,9 +282,8 @@ export default function Page() {
const key = sessionKey() const key = sessionKey()
if (key !== prev) { if (key !== prev) {
setStore("deferRender", true) setStore("deferRender", true)
const owner = sessionOwnership.capture()
requestAnimationFrame(() => { requestAnimationFrame(() => {
setTimeout(() => owner.run(() => setStore("deferRender", false)), 0) setTimeout(() => setStore("deferRender", false), 0)
}) })
} }
return key return key
@@ -594,7 +549,8 @@ export default function Page() {
on( on(
sessionKey, sessionKey,
() => { () => {
setStore(sessionViewState()) setStore("messageId", undefined)
setStore("changes", "git")
setUi("pendingMessage", undefined) setUi("pendingMessage", undefined)
}, },
{ defer: true }, { defer: true },
@@ -787,8 +743,6 @@ export default function Page() {
inputRef?.focus() inputRef?.focus()
} }
useComposerCommands()
useSettingsCommand()
useSessionCommands({ useSessionCommands({
navigateMessageByOffset, navigateMessageByOffset,
setActiveMessage, setActiveMessage,
@@ -908,13 +862,7 @@ export default function Page() {
) )
const reviewPanel = () => ( const reviewPanel = () => (
<div <div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
classList={{
"flex flex-col h-full overflow-hidden contain-strict": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
{reviewContent({ {reviewContent({
diffStyle: layout.review.diffStyle(), diffStyle: layout.review.diffStyle(),
@@ -1179,37 +1127,27 @@ export default function Page() {
let captureHistoryAnchor = () => {} let captureHistoryAnchor = () => {}
let restoreHistoryAnchor = (_done: boolean) => {} let restoreHistoryAnchor = (_done: boolean) => {}
const historyRequests = new Set<string>() let historyRequest = false
let historyContinuationFrame: number | undefined let historyContinuationFrame: number | undefined
const loadOlder = async () => { const loadOlder = async () => {
const owner = sessionOwnership.capture() if (historyRequest || historyLoading()) return
if (historyLoading() || historyRequests.has(owner.key)) return historyRequest = true
historyRequests.add(owner.key)
const before = timeline.messages().length const before = timeline.messages().length
try { try {
await timeline.history.loadOlder({ await timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
before: () => owner.run(captureHistoryAnchor),
after: (done) => owner.run(() => restoreHistoryAnchor(done)),
})
} finally { } finally {
historyRequests.delete(owner.key) historyRequest = false
} }
if (!owner.current() || timeline.messages().length <= before) return if (timeline.messages().length <= before) return
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame) if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
historyContinuationFrame = requestAnimationFrame(() => { historyContinuationFrame = requestAnimationFrame(() => {
historyContinuationFrame = undefined historyContinuationFrame = undefined
owner.run(onHistoryScroll) onHistoryScroll()
}) })
} }
const onHistoryScroll = () => { const onHistoryScroll = () => {
if ( if (historyRequest || historyLoading() || !autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200)
historyRequests.has(sessionOwnership.key()) ||
historyLoading() ||
!autoScroll.userScrolled() ||
!scroller ||
scroller.scrollTop >= 200
)
return return
void loadOlder() void loadOlder()
} }
@@ -1280,13 +1218,23 @@ export default function Page() {
}) })
} }
const merge = (next: NonNullable<ReturnType<typeof info>>, target = sync()) => target.session.remember(next) const merge = (next: NonNullable<ReturnType<typeof info>>) =>
sync().set("session", (list) => {
const idx = list.findIndex((item) => item.id === next.id)
if (idx < 0) return list
const out = list.slice()
out[idx] = next
return out
})
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => { const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"]) =>
const session = target.session.get(sessionID) sync().set("session", (list) => {
if (!session) return const idx = list.findIndex((item) => item.id === sessionID)
target.session.remember({ ...session, revert: next }) if (idx < 0) return list
} const out = list.slice()
out[idx] = { ...out[idx], revert: next }
return out
})
const busy = (sessionID: string) => sync().data.session_working(sessionID) const busy = (sessionID: string) => sync().data.session_working(sessionID)
@@ -1304,7 +1252,6 @@ export default function Page() {
const followupMutation = useMutation(() => ({ const followupMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => { mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
const owner = sessionOwnership.capture()
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id) const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
if (!item) return if (!item) return
@@ -1325,7 +1272,7 @@ export default function Page() {
if (!ok) return if (!ok) return
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id)) setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
if (input.manual) owner.run(resumeScroll) if (input.manual) resumeScroll()
}, },
})) }))
@@ -1414,23 +1361,25 @@ export default function Page() {
const revertMutation = useMutation(() => ({ const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => { mutationFn: async (input: { sessionID: string; messageID: string }) => {
const client = sdk().client const prev = prompt.current().slice()
const target = sync() const last = info()?.revert
const last = target.session.get(input.sessionID)?.revert
const value = draft(input.messageID) const value = draft(input.messageID)
await runPromptRollbackMutation({ batch(() => {
capturePrompt: prompt.capture, roll(input.sessionID, { messageID: input.messageID })
optimistic: (prompt) => { prompt.set(value)
roll(input.sessionID, { messageID: input.messageID }, target)
prompt.set(value)
},
request: () => halt(input.sessionID).then(() => client.session.revert(input)),
complete: (result) => {
if (result.data) merge(result.data, target)
},
rollback: () => roll(input.sessionID, last, target),
fail,
}) })
await halt(input.sessionID)
.then(() => sdk().client.session.revert(input))
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(input.sessionID, last)
prompt.set(prev)
})
fail(err)
})
}, },
})) }))
@@ -1439,31 +1388,39 @@ export default function Page() {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return if (!sessionID) return
const client = sdk().client
const target = sync()
const next = userMessages().find((item) => item.id > id) const next = userMessages().find((item) => item.id > id)
const last = target.session.get(sessionID)?.revert const prev = prompt.current().slice()
const last = info()?.revert
await runPromptRollbackMutation({ batch(() => {
capturePrompt: prompt.capture, roll(sessionID, next ? { messageID: next.id } : undefined)
optimistic: (promptSession) => { if (next) {
roll(sessionID, next ? { messageID: next.id } : undefined, target) prompt.set(draft(next.id))
if (next) { return
promptSession.set(draft(next.id)) }
return prompt.reset()
}
promptSession.reset()
},
request: () =>
!next
? halt(sessionID).then(() => client.session.unrevert({ sessionID }))
: halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })),
complete: (result) => {
if (result.data) merge(result.data, target)
},
rollback: () => roll(sessionID, last, target),
fail,
}) })
const task = !next
? halt(sessionID).then(() => sdk().client.session.unrevert({ sessionID }))
: halt(sessionID).then(() =>
sdk().client.session.revert({
sessionID,
messageID: next.id,
}),
)
await task
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(sessionID, last)
prompt.set(prev)
})
fail(err)
})
}, },
})) }))
@@ -1579,28 +1536,44 @@ export default function Page() {
useUsageExceededDialogs() useUsageExceededDialogs()
const composerRegion = () => { const composerRegion = (placement: "dock" | "inline") => (
const controller = createSessionComposerRegionController({ <SessionComposerRegion
state: composer, state={composer}
sessionKey, ready={!store.deferRender && messagesReady()}
sessionID: () => params.id, centered={placement === "dock" && centered()}
prompt, placement={placement}
ready: () => !store.deferRender && messagesReady(), inputRef={(el) => {
centered, inputRef = el
todo: { }}
collapsed: () => view().todoCollapsed.get(), newSessionWorktree={newSessionWorktree()}
onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()), onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
}, onSubmit={() => {
followup: () => comments.clear()
resumeScroll()
}}
onResponseSubmit={resumeScroll}
followup={
params.id && !isChildSession() params.id && !isChildSession()
? { ? {
queue: queueEnabled,
items: followupDock(), items: followupDock(),
sending: sendingFollowup(), sending: sendingFollowup(),
onSend: (id) => void sendFollowup(params.id!, id, { manual: true }), edit: editingFollowup(),
onQueue: queueFollowup,
onAbort: () => {
const id = params.id
if (!id) return
setFollowup("paused", id, true)
},
onSend: (id) => {
void sendFollowup(params.id!, id, { manual: true })
},
onEdit: editFollowup, onEdit: editFollowup,
onEditLoaded: clearFollowupEdit,
} }
: undefined, : undefined
revert: () => }
revert={
rolled().length > 0 rolled().length > 0
? { ? {
items: rolled(), items: rolled(),
@@ -1608,53 +1581,13 @@ export default function Page() {
disabled: reverting(), disabled: reverting(),
onRestore: restore, onRestore: restore,
} }
: undefined, : undefined
onResponseSubmit: resumeScroll, }
openParent: () => { setPromptDockRef={(el) => {
const id = info()?.parentID
if (!id) return
navigate(
params.serverKey
? sessionHref(requireServerKey(params.serverKey), id)
: legacySessionHref(sdk().directory, id),
)
},
setPromptRef: (el) => {
inputRef = el
},
setDockRef: (el) => {
promptDock = el promptDock = el
}, }}
}) />
return ( )
<SessionComposerRegion
controller={controller}
promptInput={
<PromptInput
controls={inputController()}
ref={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
onSubmit={() => {
comments.clear()
resumeScroll()
}}
edit={editingFollowup()}
onEditLoaded={clearFollowupEdit}
shouldQueue={queueEnabled}
onQueue={queueFollowup}
onAbort={() => {
const id = params.id
if (!id) return
setFollowup("paused", id, true)
}}
/>
}
/>
)
}
const mobileTabs = (compact = false, bottom = false) => ( const mobileTabs = (compact = false, bottom = false) => (
<Tabs value={store.mobileTab} class="h-auto"> <Tabs value={store.mobileTab} class="h-auto">
@@ -1719,9 +1652,7 @@ export default function Page() {
> >
<div <div
classList={{ classList={{
"flex-1 min-h-0 flex flex-col": true, "flex-1 min-h-0 flex flex-col bg-background-stronger": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(), "rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id, "shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}} }}
@@ -1793,7 +1724,7 @@ export default function Page() {
</Switch> </Switch>
</div> </div>
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show> <Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{composerRegion("dock")}</Show>
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show> <Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
</div> </div>
@@ -1,4 +1,2 @@
export { SessionComposerRegion } from "./session-composer-region" export { SessionComposerRegion } from "./session-composer-region"
export { createPromptInputController, createPromptProjectControls } from "./session-composer-controls" export { createSessionComposerState } from "./session-composer-state"
export { createSessionComposerController } from "./session-composer-state"
export { createSessionComposerRegionController } from "./session-composer-region-controller"
@@ -1,130 +0,0 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createQuery } from "@tanstack/solid-query"
import { useNavigate, useSearchParams } from "@solidjs/router"
import { type Accessor, createMemo } from "solid-js"
import type { PromptInputControls } from "@/components/prompt-input"
import type { PromptProjectControls } from "@/components/prompt-project-selector"
import { useDirectoryPicker } from "@/components/directory-picker"
import { useGlobal } from "@/context/global"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import type { QueryOptionsApi } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { serverName, ServerConnection, useServer } from "@/context/server"
import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
import { useProviders } from "@/hooks/use-providers"
import { pathKey } from "@/utils/path-key"
export function createPromptInputController(input: {
sessionKey: Accessor<string>
sessionID: Accessor<string | undefined>
queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
}) {
const layout = useLayout()
const local = useLocal()
const providers = useProviders()
const settings = useSettings()
const sync = useSync()
const sdk = useSDK()
const view = layout.view(input.sessionKey)
const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
return createMemo<PromptInputControls>(() => ({
agents: {
available: sync().data.agent,
options: local.agent.list().map((agent) => agent.name),
current: local.agent.current()?.name ?? "",
loading: agentsQuery.isLoading,
visible: settings.visibility.customAgents(),
select: local.agent.set,
},
model: {
selection: local.model,
paid: providers.paid().length > 0,
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
},
session: {
id: input.sessionID(),
tabs: layout.tabs(input.sessionKey),
reviewPanel: view.reviewPanel,
},
newLayoutDesigns: settings.general.newLayoutDesigns(),
}))
}
export function createPromptProjectControls() {
const navigate = useNavigate()
const layout = useLayout()
const server = useServer()
const serverSDK = useServerSDK()
const sdk = useSDK()
const tabs = useTabs()
const global = useGlobal()
const pickDirectory = useDirectoryPicker()
const [search] = useSearchParams<{ draftId?: string }>()
const projectServer = () => serverSDK().server
const projectServerCtx = createMemo(() => global.ensureServerCtx(projectServer()))
const projects = createMemo(() => {
if (server.list.length <= 1) {
return search.draftId ? projectServerCtx().projects.list() : layout.projects.list()
}
return server.list.flatMap((conn) => {
const item = { key: ServerConnection.key(conn), name: serverName(conn) }
return global
.ensureServerCtx(conn)
.projects.list()
.map((project) => ({ ...project, server: item }))
})
})
const selectProject = (worktree: string, serverKey?: string) => {
const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer()
if (search.draftId) {
if (!conn) return
const target = global.ensureServerCtx(conn)
target.projects.open(worktree)
target.projects.touch(worktree)
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
return
}
if (!serverKey) {
layout.projects.open(worktree)
server.projects.touch(worktree)
navigate(`/${base64Encode(worktree)}/session`)
return
}
if (!conn) return
const target = global.ensureServerCtx(conn)
target.projects.open(worktree)
target.projects.touch(worktree)
server.setActive(ServerConnection.key(conn))
navigate(`/${base64Encode(worktree)}/session`)
}
const addProject = (title: string, serverKey?: string) => {
const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer()
if (!conn) return
pickDirectory({
server: conn,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
if (directory) selectProject(directory, serverKey)
},
})
}
return createMemo<PromptProjectControls>(() => ({
available: projects(),
directory: sdk().directory,
server: server.list.length > 1 ? ServerConnection.key(projectServer()) : undefined,
select: selectProject,
add: addProject,
}))
}
@@ -1,145 +0,0 @@
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { PromptInputState } from "@/components/prompt-input"
import { useSync } from "@/context/sync"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import type { SessionComposerController } from "./session-composer-state"
export type SessionComposerFollowupDock = {
items: { id: string; text: string }[]
sending?: string
onSend: (id: string) => void
onEdit: (id: string) => void
}
export type SessionComposerRevertDock = {
items: { id: string; text: string }[]
restoring?: string
disabled?: boolean
onRestore: (id: string) => void
}
export function createSessionComposerRegionController(input: {
state: SessionComposerController
sessionKey: Accessor<string>
sessionID: Accessor<string | undefined>
prompt: PromptInputState
ready: Accessor<boolean>
centered: Accessor<boolean>
todo: {
collapsed: Accessor<boolean>
onToggle: () => void
}
followup: Accessor<SessionComposerFollowupDock | undefined>
revert: Accessor<SessionComposerRevertDock | undefined>
onResponseSubmit: () => void
openParent: () => void
setPromptRef: (el: HTMLDivElement) => void
setDockRef: (el: HTMLDivElement) => void
}) {
const sync = useSync()
const [store, setStore] = createStore({
ready: input.ready() || input.state.dock(),
height: 320,
body: undefined as HTMLDivElement | undefined,
})
let timer: number | undefined
let frame: number | undefined
const clear = () => {
if (timer !== undefined) window.clearTimeout(timer)
if (frame !== undefined) cancelAnimationFrame(frame)
timer = undefined
frame = undefined
}
createEffect(() => {
input.sessionKey()
const ready = input.ready()
const dock = input.state.dock()
clear()
if (store.ready || (!ready && !dock)) return
if (dock) {
setStore("ready", true)
return
}
frame = requestAnimationFrame(() => {
frame = undefined
timer = window.setTimeout(() => {
setStore("ready", true)
timer = undefined
}, 140)
})
})
createEffect(() => {
if (!input.prompt.ready()) return
setSessionHandoff(input.sessionKey(), {
prompt: input.prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim(),
})
})
createEffect(() => {
const el = store.body
if (!el) return
const update = () => setStore("height", el.getBoundingClientRect().height)
createResizeObserver(el, update)
update()
})
onCleanup(clear)
const parentID = createMemo(() => {
const id = input.sessionID()
return id ? sync().session.get(id)?.parentID : undefined
})
const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing())
const progress = useSpring(
() => (open() ? 1 : 0),
{ visualDuration: 0.3, bounce: 0 },
() => `${input.sessionKey()}\0${store.ready}`,
)
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
const ready = Promise.resolve()
const [promptReady] = createResource(
() => input.prompt.ready.promise ?? ready,
(promise) => promise.then(() => true),
)
return {
state: input.state,
centered: input.centered,
todo: input.todo,
followup: input.followup,
revert: input.revert,
onResponseSubmit: input.onResponseSubmit,
openParent: input.openParent,
setPromptRef: input.setPromptRef,
setDockRef: input.setDockRef,
parentID,
child: () => !!parentID(),
showComposer: () => !input.state.blocked() || !!parentID(),
handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt,
promptReady: () => input.prompt.ready() || promptReady(),
dock: () => (store.ready && input.state.dock()) || value() > 0.001,
dockProgress: value,
dockHeight: () => Math.max(78, store.height),
lift: () => (input.revert()?.items.length ? 18 : 36 * value()),
setDockBodyRef: (el: HTMLDivElement) => setStore("body", el),
}
}
export type SessionComposerRegionController = ReturnType<typeof createSessionComposerRegionController>
@@ -1,89 +1,297 @@
import { Show, type JSX } from "solid-js" import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate, useSearchParams } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { useLayout } from "@/context/layout"
import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { usePrompt } from "@/context/prompt"
import { useSync } from "@/context/sync"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { useSessionKey } from "@/pages/session/session-layout"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock" import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock" import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller" import type { FollowupDraft } from "@/components/prompt-input/submit"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
import { createQuery } from "@tanstack/solid-query"
import { useQueryOptions } from "@/context/server-sync"
import { useSDK } from "@/context/sdk"
import { pathKey } from "@/utils/path-key"
import { useLocal } from "@/context/local"
import { useProviders } from "@/hooks/use-providers"
import { useSettings } from "@/context/settings"
import { ServerConnection, useServer } from "@/context/server"
import { type DraftTab, useTabs } from "@/context/tabs"
import { useDirectoryPicker } from "@/components/directory-picker"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useGlobal } from "@/context/global"
export function SessionComposerRegion(props: { export function SessionComposerRegion(props: {
controller: SessionComposerRegionController state: SessionComposerState
promptInput: JSX.Element ready: boolean
}) { centered: boolean
const language = useLanguage() placement?: "dock" | "inline"
const controller = props.controller inputRef: (el: HTMLDivElement) => void
const settings = useSettings() newSessionWorktree: string
const rolled = () => { onNewSessionWorktreeReset: () => void
const revert = controller.revert() onSubmit: () => void
return revert?.items.length ? revert : undefined onResponseSubmit: () => void
followup?: {
queue: () => boolean
items: { id: string; text: string }[]
sending?: string
edit?: { id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] }
onQueue: (draft: FollowupDraft) => void
onAbort: () => void
onSend: (id: string) => void
onEdit: (id: string) => void
onEditLoaded: () => void
} }
revert?: {
items: { id: string; text: string }[]
restoring?: string
disabled?: boolean
onRestore: (id: string) => void
}
setPromptDockRef: (el: HTMLDivElement) => void
}) {
const navigate = useNavigate()
const layout = useLayout()
const prompt = usePrompt()
const language = useLanguage()
const route = useSessionKey()
const sync = useSync()
const sdk = useSDK()
const queryOptions = useQueryOptions()
const local = useLocal()
const providers = useProviders()
const settings = useSettings()
const server = useServer()
const tabs = useTabs()
const global = useGlobal()
const pickDirectory = useDirectoryPicker()
const [search] = useSearchParams<{ draftId?: string }>()
const view = layout.view(route.sessionKey)
const draft = createMemo(() => {
if (!search.draftId) return
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
})
const projectServer = createMemo(() => {
if (!search.draftId) return server.current
const target = draft()?.server
if (!target) return
return server.list.find((conn) => ServerConnection.key(conn) === target)
})
const projectServerCtx = createMemo(() => {
const conn = projectServer()
if (conn) return global.ensureServerCtx(conn)
})
const projects = createMemo(() =>
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
)
const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
const selectProject = (worktree: string) => {
const conn = projectServer()
const target = projectServerCtx()
if (search.draftId) {
if (!conn || !target) return
target.projects.open(worktree)
target.projects.touch(worktree)
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
return
}
layout.projects.open(worktree)
server.projects.touch(worktree)
navigate(`/${base64Encode(worktree)}/session`)
}
const addProject = (title: string) => {
const conn = projectServer()
if (!conn) return
pickDirectory({
server: conn,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
if (directory) selectProject(directory)
},
})
}
const controls = createMemo(() => ({
agents: {
available: sync().data.agent,
options: local.agent.list().map((agent) => agent.name),
current: local.agent.current()?.name ?? "",
loading: agentsQuery.isLoading,
visible: settings.visibility.customAgents(),
select: local.agent.set,
},
model: {
selection: local.model,
paid: providers.paid().length > 0,
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
},
projects: {
available: projects(),
directory: sdk().directory,
select: selectProject,
add: addProject,
},
session: {
id: route.params.id,
tabs: layout.tabs(route.sessionKey),
reviewPanel: view.reviewPanel,
},
newLayoutDesigns: settings.general.newLayoutDesigns(),
}))
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
const info = createMemo(() => (route.params.id ? sync().session.get(route.params.id) : undefined))
const parentID = createMemo(() => info()?.parentID)
const child = createMemo(() => !!parentID())
const showComposer = createMemo(() => !props.state.blocked() || child())
const previewPrompt = () =>
prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim()
createEffect(() => {
if (!prompt.ready()) return
setSessionHandoff(route.sessionKey(), { prompt: previewPrompt() })
})
const [store, setStore] = createStore({
ready: false,
height: 320,
body: undefined as HTMLDivElement | undefined,
})
let timer: number | undefined
let frame: number | undefined
const clear = () => {
if (timer !== undefined) {
window.clearTimeout(timer)
timer = undefined
}
if (frame !== undefined) {
cancelAnimationFrame(frame)
frame = undefined
}
}
createEffect(() => {
route.sessionKey()
const ready = props.ready
const delay = 140
clear()
setStore("ready", false)
if (!ready) return
frame = requestAnimationFrame(() => {
frame = undefined
timer = window.setTimeout(() => {
setStore("ready", true)
timer = undefined
}, delay)
})
})
onCleanup(clear)
const open = createMemo(() => store.ready && props.state.dock() && !props.state.closing())
const progress = useSpring(() => (open() ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
const dock = createMemo(() => (store.ready && props.state.dock()) || value() > 0.001)
const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined))
const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
const full = createMemo(() => Math.max(78, store.height))
const openParent = () => {
const id = parentID()
if (!id) return
navigate(
route.params.serverKey
? sessionHref(requireServerKey(route.params.serverKey), id)
: legacySessionHref(sdk().directory, id),
)
}
createEffect(() => {
const el = store.body
if (!el) return
const update = () => setStore("height", el.getBoundingClientRect().height)
createResizeObserver(store.body, update)
update()
})
const ready = Promise.resolve()
const [promptReadyResource] = createResource(
() => prompt.ready.promise ?? ready,
(promise) => promise.then(() => true),
)
return ( return (
<div <div
ref={controller.setDockRef} ref={props.setPromptDockRef}
data-component="session-prompt-dock" data-component="session-prompt-dock"
classList={{ classList={{
"w-full shrink-0 flex flex-col justify-center items-center pb-3 pointer-events-none": true, "w-full flex flex-col justify-center items-center pointer-events-none": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(), "shrink-0 pb-3 bg-background-stronger": props.placement !== "inline",
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}} }}
> >
<div <div
classList={{ classList={{
"w-full px-3 pointer-events-auto": true, "w-full pointer-events-auto": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": controller.centered(), "px-3": props.placement !== "inline",
[NEW_SESSION_CONTENT_WIDTH]: props.placement === "inline",
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}} }}
> >
<Show when={controller.state.questionRequest()} keyed> <Show when={props.state.questionRequest()} keyed>
{(request) => ( {(request) => (
<div> <div>
<SessionQuestionDock request={request} onSubmit={controller.onResponseSubmit} /> <SessionQuestionDock request={request} onSubmit={props.onResponseSubmit} />
</div> </div>
)} )}
</Show> </Show>
<Show when={controller.state.permissionRequest()} keyed> <Show when={props.state.permissionRequest()} keyed>
{(request) => ( {(request) => (
<div> <div>
<SessionPermissionDock <SessionPermissionDock
request={request} request={request}
responding={controller.state.permissionResponding()} responding={props.state.permissionResponding()}
onDecide={(response) => { onDecide={(response) => {
controller.onResponseSubmit() props.onResponseSubmit()
controller.state.decide(response) props.state.decide(response)
}} }}
/> />
</div> </div>
)} )}
</Show> </Show>
<Show when={controller.showComposer()}> <Show when={showComposer()}>
<Show when={controller.dock()}>
<div
classList={{
"overflow-hidden": true,
"pointer-events-none": controller.dockProgress() < 0.98,
}}
style={{
"max-height": `${controller.dockHeight() * controller.dockProgress()}px`,
}}
>
<div ref={controller.setDockBodyRef}>
<SessionTodoDock
todos={controller.state.todos()}
collapsed={controller.todo.collapsed()}
onToggle={controller.todo.onToggle}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
dockProgress={controller.dockProgress()}
/>
</div>
</div>
</Show>
<Show <Show
when={controller.promptReady()} when={promptReadyResource()}
fallback={ fallback={
<> <>
<Show when={rolled()} keyed> <Show when={rolled()} keyed>
@@ -98,20 +306,40 @@ export function SessionComposerRegion(props: {
</div> </div>
)} )}
</Show> </Show>
<div <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none" {handoffPrompt() || language.t("prompt.loading")}
style={{ "margin-top": `${-36 * controller.dockProgress()}px` }}
>
{controller.handoffPrompt() || language.t("prompt.loading")}
</div> </div>
</> </>
} }
> >
<Show when={dock()}>
<div
classList={{
"overflow-hidden": true,
"pointer-events-none": value() < 0.98,
}}
style={{
"max-height": `${full() * value()}px`,
}}
>
<div ref={(el) => setStore("body", el)}>
<SessionTodoDock
sessionID={route.params.id}
todos={props.state.todos()}
collapsed={view.todoCollapsed.get()}
onToggle={() => view.todoCollapsed.set(!view.todoCollapsed.get())}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
dockProgress={value()}
/>
</div>
</div>
</Show>
<Show when={rolled()} keyed> <Show when={rolled()} keyed>
{(revert) => ( {(revert) => (
<div <div
style={{ style={{
"margin-top": `${-36 * controller.dockProgress()}px`, "margin-top": `${-36 * value()}px`,
}} }}
> >
<SessionRevertDock <SessionRevertDock
@@ -125,34 +353,50 @@ export function SessionComposerRegion(props: {
</Show> </Show>
<div <div
classList={{ classList={{
"relative z-[70]": true, "relative z-10": true,
}} }}
style={{ style={{
"margin-top": `${-controller.lift()}px`, "margin-top": `${-lift()}px`,
}} }}
> >
<Show when={controller.followup()?.items.length}> <Show when={props.followup?.items.length}>
<SessionFollowupDock <SessionFollowupDock
items={controller.followup()!.items} items={props.followup!.items}
sending={controller.followup()!.sending} sending={props.followup!.sending}
onSend={controller.followup()!.onSend} onSend={props.followup!.onSend}
onEdit={controller.followup()!.onEdit} onEdit={props.followup!.onEdit}
/> />
</Show> </Show>
<Show <Show
when={controller.child()} when={child()}
fallback={<Show when={!controller.state.blocked()}>{props.promptInput}</Show>} fallback={
<Show when={!props.state.blocked()}>
<PromptInput
controls={controls()}
variant={props.placement === "inline" ? "new-session" : undefined}
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
edit={props.followup?.edit}
onEditLoaded={props.followup?.onEditLoaded}
shouldQueue={props.followup?.queue}
onQueue={props.followup?.onQueue}
onAbort={props.followup?.onAbort}
onSubmit={props.onSubmit}
/>
</Show>
}
> >
<div <div
ref={controller.setPromptRef} ref={props.inputRef}
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak" class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
> >
<span>{language.t("session.child.promptDisabled")} </span> <span>{language.t("session.child.promptDisabled")} </span>
<Show when={controller.parentID()}> <Show when={parentID()}>
<button <button
type="button" type="button"
class="text-text-base transition-colors hover:text-text-strong" class="text-text-base transition-colors hover:text-text-strong"
onClick={controller.openParent} onClick={openParent}
> >
{language.t("session.child.backToParent")} {language.t("session.child.backToParent")}
</button> </button>
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { todoDockAtBoundary, todoState } from "./session-composer-state" import { todoState } from "./session-composer-state"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
const session = (input: { id: string; parentID?: string }) => const session = (input: { id: string; parentID?: string }) =>
@@ -126,13 +126,3 @@ describe("todoState", () => {
expect(todoState({ count: 2, done: true, live: false })).toBe("clear") expect(todoState({ count: 2, done: true, live: false })).toBe("clear")
}) })
}) })
describe("todoDockAtBoundary", () => {
test("shows active todos when entering a session", () => {
expect(todoDockAtBoundary("open")).toBe(true)
})
test("hides completed todos when entering a session", () => {
expect(todoDockAtBoundary("close")).toBe(false)
})
})
@@ -21,11 +21,9 @@ export const todoState = (input: {
return "close" return "close"
} }
export const todoDockAtBoundary = (state: ReturnType<typeof todoState>) => state === "open"
const idle = { type: "idle" as const } const idle = { type: "idle" as const }
export function createSessionComposerController(options?: { closeMs?: number | (() => number) }) { export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
const params = useParams() const params = useParams()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
@@ -62,9 +60,8 @@ export function createSessionComposerController(options?: { closeMs?: number | (
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked()) const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
const [store, setStore] = createStore({ const [store, setStore] = createStore({
sessionID: params.id,
responding: undefined as string | undefined, responding: undefined as string | undefined,
dock: todos().length > 0 && !done() && live(), dock: todos().length > 0 && live(),
closing: false, closing: false,
opening: false, opening: false,
}) })
@@ -119,8 +116,8 @@ export function createSessionComposerController(options?: { closeMs?: number | (
createEffect( createEffect(
on( on(
() => [params.id, todos().length, done(), live()] as const, () => [todos().length, done(), live()] as const,
([id, count, complete, active], previous) => { ([count, complete, active]) => {
if (raf) cancelAnimationFrame(raf) if (raf) cancelAnimationFrame(raf)
raf = undefined raf = undefined
@@ -130,14 +127,6 @@ export function createSessionComposerController(options?: { closeMs?: number | (
live: active, live: active,
}) })
if (!previous || previous[0] !== id) {
if (timer) window.clearTimeout(timer)
timer = undefined
setStore({ sessionID: id, dock: todoDockAtBoundary(next), closing: false, opening: false })
if (next === "clear") clear()
return
}
if (next === "hide") { if (next === "hide") {
if (timer) window.clearTimeout(timer) if (timer) window.clearTimeout(timer)
timer = undefined timer = undefined
@@ -192,13 +181,10 @@ export function createSessionComposerController(options?: { closeMs?: number | (
permissionResponding, permissionResponding,
decide, decide,
todos, todos,
dock: () => dock: () => store.dock,
store.sessionID === params.id closing: () => store.closing,
? store.dock opening: () => store.opening,
: todoDockAtBoundary(todoState({ count: todos().length, done: done(), live: live() })),
closing: () => store.sessionID === params.id && store.closing,
opening: () => store.sessionID === params.id && store.opening,
} }
} }
export type SessionComposerController = ReturnType<typeof createSessionComposerController> export type SessionComposerState = ReturnType<typeof createSessionComposerState>
@@ -432,23 +432,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
header={ header={
<> <>
<div data-slot="question-header-title">{summary()}</div> <div data-slot="question-header-title">{summary()}</div>
<Show when={total() > 1}> <div data-slot="question-progress">
<div data-slot="question-progress"> <For each={questions()}>
<For each={questions()}> {(_, i) => (
{(_, i) => ( <button
<button type="button"
type="button" data-slot="question-progress-segment"
data-slot="question-progress-segment" data-active={i() === store.tab}
data-active={i() === store.tab} data-answered={answered(i())}
data-answered={answered(i())} disabled={sending()}
disabled={sending()} onClick={() => jump(i())}
onClick={() => jump(i())} aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} />
/> )}
)} </For>
</For> </div>
</div>
</Show>
</> </>
} }
footer={ footer={
@@ -40,6 +40,7 @@ function dot(status: Todo["status"]) {
} }
export function SessionTodoDock(props: { export function SessionTodoDock(props: {
sessionID?: string
todos: Todo[] todos: Todo[]
collapsed: boolean collapsed: boolean
onToggle: () => void onToggle: () => void
@@ -49,7 +50,7 @@ export function SessionTodoDock(props: {
}) { }) {
const language = useLanguage() const language = useLanguage()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
height: 78, height: 320,
}) })
const total = createMemo(() => props.todos.length) const total = createMemo(() => props.todos.length)
@@ -84,7 +85,7 @@ export function SessionTodoDock(props: {
const el = contentRef const el = contentRef
if (!el) return if (!el) return
const update = () => { const update = () => {
setStore("height", (height) => Math.max(height, el.scrollHeight)) setStore("height", el.getBoundingClientRect().height)
} }
update() update()
createResizeObserver(el, update) createResizeObserver(el, update)
@@ -3,13 +3,7 @@ import { createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { Todo } from "@opencode-ai/sdk/v2" import type { Todo } from "@opencode-ai/sdk/v2"
import { useServerSync } from "@/context/global-sync" import { useServerSync } from "@/context/global-sync"
import { PromptInput } from "@/components/prompt-input" import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
import { usePrompt } from "@/context/prompt"
import {
SessionComposerRegion,
createSessionComposerController,
createSessionComposerRegionController,
} from "@/pages/session/composer"
export default { export default {
title: "UI/Todo Panel Motion", title: "UI/Todo Panel Motion",
@@ -56,24 +50,6 @@ const btn = (accent?: boolean) =>
"font-size": "13px", "font-size": "13px",
}) as const }) as const
const controls = {
agents: { available: [], options: ["build"], current: "build", loading: false, visible: true, select: () => {} },
model: {
selection: {
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
variant: { list: () => [], current: () => undefined, set: () => {} },
},
paid: true,
loading: false,
},
session: {
id: "story-session",
tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
reviewPanel: { opened: () => false, open: () => {} },
},
newLayoutDesigns: true,
}
const css = ` const css = `
[data-component="todo-stage"] { [data-component="todo-stage"] {
display: grid; display: grid;
@@ -154,10 +130,8 @@ const css = `
export const Playground = { export const Playground = {
render: () => { render: () => {
const global = useServerSync() const global = useServerSync()
const prompt = usePrompt()
const [cfg, setCfg] = createStore({ const [cfg, setCfg] = createStore({
open: true, open: true,
collapsed: false,
step: 1, step: 1,
dockOpenDuration: 0.3, dockOpenDuration: 0.3,
dockOpenBounce: 0, dockOpenBounce: 0,
@@ -194,8 +168,9 @@ export const Playground = {
const countMask = () => cfg.countMask const countMask = () => cfg.countMask
const countMaskHeight = () => cfg.countMaskHeight const countMaskHeight = () => cfg.countMaskHeight
const countWidthDuration = () => cfg.countWidthDuration const countWidthDuration = () => cfg.countWidthDuration
const state = createSessionComposerController({ closeMs: () => Math.round(dockCloseDuration() * 1000) }) const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
let frame let frame
let composerRef
let scrollRef let scrollRef
const todos = createMemo<Todo[]>(() => { const todos = createMemo<Todo[]>(() => {
@@ -221,8 +196,16 @@ export const Playground = {
scrollRef.scrollTop = scrollRef.scrollHeight scrollRef.scrollTop = scrollRef.scrollHeight
} }
const collapsed = () => cfg.collapsed const collapsed = () =>
const setCollapsed = (value: boolean) => setCfg("collapsed", value) !!composerRef?.querySelector('[data-action="session-todo-toggle-button"][data-collapsed="true"]')
const setCollapsed = (value: boolean) => {
const button = composerRef?.querySelector('[data-action="session-todo-toggle-button"]')
if (!(button instanceof HTMLButtonElement)) return
if (collapsed() === value) return
button.click()
}
const openDock = () => { const openDock = () => {
clear() clear()
setCfg("open", true) setCfg("open", true)
@@ -284,32 +267,31 @@ export const Playground = {
</div> </div>
</div> </div>
<div> <div ref={composerRef}>
<SessionComposerRegion <SessionComposerRegion
controller={createSessionComposerRegionController({ state={state}
state, centered={false}
sessionKey: () => "story-session", inputRef={() => {}}
sessionID: () => "story-session", newSessionWorktree=""
prompt, onNewSessionWorktreeReset={() => {}}
ready: () => true, onSubmit={() => {}}
centered: () => false, onResponseSubmit={pin}
todo: { collapsed, onToggle: () => setCollapsed(!collapsed()) }, setPromptDockRef={() => {}}
followup: () => undefined, dockOpenVisualDuration={dockOpenDuration()}
revert: () => undefined, dockOpenBounce={dockOpenBounce()}
onResponseSubmit: pin, dockCloseVisualDuration={dockCloseDuration()}
openParent: () => {}, dockCloseBounce={dockCloseBounce()}
setPromptRef: () => {}, drawerExpandVisualDuration={drawerExpandDuration()}
setDockRef: () => {}, drawerExpandBounce={drawerExpandBounce()}
})} drawerCollapseVisualDuration={drawerCollapseDuration()}
promptInput={ drawerCollapseBounce={drawerCollapseBounce()}
<PromptInput subtitleDuration={subtitleDuration()}
controls={controls} subtitleTravel={subtitleAuto() ? undefined : subtitleTravel()}
submission={{ abort: () => {}, handleSubmit: (event) => event.preventDefault() }} subtitleEdge={subtitleAuto() ? undefined : subtitleEdge()}
ref={() => {}} countDuration={countDuration()}
newSessionWorktree="" countMask={countMask()}
onNewSessionWorktreeReset={() => {}} countMaskHeight={countMaskHeight()}
/> countWidthDuration={countWidthDuration()}
}
/> />
</div> </div>
</div> </div>
@@ -1,37 +0,0 @@
import { createComputed, onCleanup } from "solid-js"
export function createSessionOwnership(sessionKey: () => string) {
let current = sessionKey()
let generation = 0
const transition = () => {
const next = sessionKey()
if (next === current) return
current = next
generation++
}
createComputed(transition)
onCleanup(() => generation++)
return {
key: () => {
transition()
return `${generation}:${current}`
},
capture() {
transition()
const captured = generation
return {
key: `${captured}:${current}`,
current: () => {
transition()
return generation === captured
},
run<T>(action: () => T) {
transition()
if (generation !== captured) return
return action()
},
}
},
}
}

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