Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton d6639cb5ad fix(tui): harden highlight cache lifecycle 2026-08-05 14:06:35 -04:00
Kit Langton b300116d0a fix(tui): cache syntax highlights across tabs 2026-08-05 14:00:00 -04:00
503 changed files with 33493 additions and 18393 deletions
+17 -1
View File
@@ -64,6 +64,7 @@
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*", "@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -111,7 +112,6 @@
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"happy-dom": "20.11.1",
"tw-animate-css": "1.4.0", "tw-animate-css": "1.4.0",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
@@ -489,6 +489,18 @@
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
}, },
}, },
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.8",
"dependencies": {
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.18.8", "version": "1.18.8",
@@ -2055,6 +2067,8 @@
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"], "@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
"@opencode-ai/effect-sqlite-node": ["@opencode-ai/effect-sqlite-node@workspace:packages/effect-sqlite-node"],
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"], "@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
@@ -6319,6 +6333,8 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/app/@opencode-ai/sdk": ["@opencode-ai/sdk@vendor/opencode-ai-sdk-1.18.8-dev.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-C2nfk4x0sPINwE5V6DPkFSuH3PkUmKPWHPzxpXC1j+3Ui5hslLCWJbkk8WcOG1Lyt3C0+yp4ea64v/kmtYCO4w=="],
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="], "@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
+26 -206
View File
@@ -5,27 +5,8 @@
- Use the `dev` branch database schema and migration registry as the V1 baseline. - Use the `dev` branch database schema and migration registry as the V1 baseline.
- Remove migrations that exist only on the V2 branch. - Remove migrations that exist only on the V2 branch.
- Generate one canonical migration from the `dev` schema to the final V2 schema. - Generate one canonical migration from the `dev` schema to the final V2 schema.
- Keep the canonical migration focused on schema changes and dropping obsolete tables. - Add explicit data operations to that migration where generated DDL is insufficient.
- Run the V1 history backfill through an experimental server endpoint invoked by the CLI before it opens the TUI. - Test the migration against a populated database at the exact `dev` schema.
- Show committed session progress while the endpoint runs.
Expose `GET /api/experimental/migration/v1` for status and a blocking `POST /api/experimental/migration/v1` to run or
resume the backfill. The status is `required`, `running`, or `completed`. On startup, the CLI checks status first and
renders no migration UI when it is already complete. For required or running status, it shows a spinner and waits for the
blocking POST without a request timeout. While migration runs, poll GET once per second and render completed and total
session counts. GET derives total from all session rows and completed from rows through the stored cursor; the count
advances only after a session transaction commits. The POST returns `{ status: "completed" }`. Do not add a background
job or streaming progress protocol. Interrupted calls resume from the stored cursor.
Initially, only interactive TUI startup performs this check; noninteractive run, ACP, raw API, service, health, version,
and help flows do not trigger the backfill.
Keep migration behavior in Core: status, semaphore, checkpointing, V1 decoding, transformation, and database writes.
Protocol owns the experimental GET/POST contracts, Server handlers delegate to Core, and the interactive CLI owns only
the status check and spinner presentation.
Guard the endpoint with one process-local Effect `Semaphore`. Concurrent callers wait; after the active call completes,
waiting callers acquire the permit, observe the completion key, and return immediately. No distributed lock is required
for the current single elected server process.
## Preserve ## Preserve
@@ -34,35 +15,20 @@ The canonical V1 data remains in its existing tables. In particular, preserve `s
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
workspace relationships. workspace relationships.
Preserve existing non-null `session.agent` and `session.model` selections. Fill missing values from the latest ordinary Keep the `todo` table and its data unchanged. V2 does not currently migrate todos into another representation, and the
V1 user message ordered by `time_created` and `id`, excluding compaction and subtask-only messages. Copy agent, provider generated migration must not drop the table.
ID, model ID, and variant, normalizing an absent variant to `default`.
Recompute session usage aggregates from all canonical V1 assistant messages, including compaction or other internal ## Truncate
assistants omitted from the V2 projection. Overwrite session cost and input, output, reasoning, cache-read, and
cache-write token totals with those sums.
Clear persisted `session.revert` state. A staged revert is transient operational state and may refer to omitted projection Truncate these pre-launch V2 tables before applying schema changes:
rows or unavailable snapshots; it must not resume automatically after upgrading. Preserve the underlying messages,
parts, and file history.
Clear `session.time_compacting`, leave the new `time_suspended` column as `NULL`, and preserve session creation, update, - `event`
and archive timestamps. Preserve project `time_initialized`; it is unrelated durable state. - `event_sequence`
- `session_message`
Keep the legacy `todo` table and its data physically unchanged, but do not include it in the final V2 Drizzle schema. These rows are not canonical V1 data. Truncating `event` before adding the required `event.created` column means the
After generation, remove the generated `DROP TABLE todo` statement from the canonical migration so the table remains as column needs neither a backfill nor a default. After truncation, rebuild `session_message` from canonical V1 `message`
unmanaged legacy storage. and `part` rows rather than retaining its pre-launch V2 contents.
## Per-Session Replacement
Do not truncate `event`, `event_sequence`, or `session_message` globally before the backfill. A whole-table delete can
hold SQLite's writer lock long enough to block the running TUI.
Replace each legacy session's V2 state inside that session's checkpointed migration transaction. Delete `event` rows for
the session aggregate, delete its `session_message` rows, rebuild its projection from canonical V1 `message` and `part`
rows, and overwrite its `event_sequence` watermark. If migration of that session fails, all replacements roll back and
the durable cursor remains at the previously committed session. Rows owned by sessions outside the legacy migration set
remain untouched.
## Message Backfill ## Message Backfill
@@ -70,23 +36,9 @@ Backfill canonical V1 history from `message` and `part` into `session_message`.
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
V2 session APIs, which read `session_message`. V2 session APIs, which read `session_message`.
Do not fail the whole migration when a V1 message or part payload cannot be decoded. Skip an undecodable message's V2
projection and log its session and message IDs. Skip an undecodable part while continuing to map its message, and perform
special-message pairing only with decoded rows. Assign sequences after filtering. Leave every malformed source row
untouched in the V1 tables.
Skip and log orphan parts whose source message does not exist and parts with unknown or unsupported types. Continue
migrating the owning message and other valid parts. Include session, message, part ID, and observed type in warnings, and
leave skipped source rows unchanged.
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
avoid rewriting other persisted state that may refer to a message. avoid rewriting other persisted state that may refer to a message.
For ordinary user and assistant rows, preserve source `message.time_created` and `message.time_updated`. Entirely
synthetic messages preserve their source timestamps, and synthetic rows split from mixed messages use the source user
timestamps. A collapsed compaction uses the compaction user creation time and the later update time of the compaction
user and summary assistant. Keep payload creation/completion times consistent with row timestamps.
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
contiguous `session_message.seq` values starting at `0`. contiguous `session_message.seq` values starting at `0`.
@@ -94,122 +46,15 @@ Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becom
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2 V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
payload. payload.
Keep ordinary messages even when their transformed payload becomes empty after filtering. Preserve an empty V2 user row
with `text: ""` and an empty V2 assistant row with `content: []` so IDs, chronology, and conversation structure remain
stable. Omit only explicitly dropped internal concepts and undecodable messages.
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special `compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
part mappings must be decided explicitly before implementing the backfill. part mappings must be decided explicitly before implementing the backfill.
Do not carry the V1 subtask concept into the V2 projection. Omit user messages containing only `subtask` parts and omit
the paired assistant task-tool messages generated from those markers. For mixed user messages, ignore the `subtask`
parts while preserving ordinary content, and still omit assistant task-tool messages generated by the skipped subtasks.
Keep all source rows unchanged in the V1 `message` and `part` tables.
Map ordinary V1 assistant `text` and `reasoning` parts into the V2 assistant `content` array in part order. Preserve text,
including empty assistant text parts used as structural separators. Map V1 part metadata to optional V2 provider state.
For reasoning, map `time.start` to `time.created` and optional `time.end` to `time.completed`.
Preserve V1 tool parts that are `pending` or `running`, but convert them to terminal V2 tool error states. Preserve the
call ID, tool name, parsed input, metadata, and available start time. Use the assistant message creation time when the V1
state has no start time. Set the error to type `tool.interrupted` with message
`Tool execution was interrupted before V2 migration`. Never resume migrated tool executions.
For a completed V1 tool part, use `callID` as the V2 tool content ID and preserve the tool name and parsed input. Set the
state to `completed`. Convert V1 output into the first text content item and convert stored output attachments into
following file content items with their URI, MIME type, and filename. Preserve state metadata. Map `time.start` to
`time.created` and `time.end` to `time.completed`. When `time.compacted` exists, use
`[Old tool result content cleared]` as the only output and omit attachments.
For a failed V1 tool part, preserve the call ID, tool name, parsed input, metadata, and timestamps, and set the V2 state
to `error`. Convert the V1 error string to a structured error with type `tool.execution`. If V1 metadata contains a string
`output`, preserve it as optional V2 text content. Map `time.start` to `time.created` and `time.end` to `time.completed`.
For an ordinary V1 assistant message, preserve agent, provider ID, model ID, optional variant, creation and completion
times, cost, and input/output/reasoning/cache token counts. Use `default` when the V1 variant is absent. Ignore V1
`tokens.total` because it is derivable and V2 does not persist it.
Use V1 assistant `parentID` only while pairing compactions and skipped subtasks with their originating user messages. Do
not persist it in ordinary V2 assistant rows; V2 uses ordered history rather than user/assistant parent links.
Ignore the optional V1 assistant `structured` output value. V2 has no equivalent top-level assistant field, and visible
text and tool content are migrated separately. Retain the original structured value only in the V1 `message` row.
Ignore V1 assistant `mode` and historical `path` (`cwd` and `root`). Mode is redundant with the preserved assistant
agent, and historical filesystem paths do not belong to the V2 assistant message contract. Retain them only in the V1
`message` row.
For assistant finish reasons, preserve `stop`, `length`, `tool-calls`, `content-filter`, `error`, and `unknown`. Map every
other nonempty V1 finish value to `unknown`, and leave the field absent when V1 omitted it. Do not retain unrecognized raw
finish values in metadata.
Map V1 assistant errors into the current V2 `{ type, message }` storage shape. Normalize Auth, content-filter, context
overflow, structured-output, output-length, aborted, API, and unknown errors to the established V2 string conventions,
preserve the message, and discard V1-only retryability and raw provider details.
Ignore V1 `retry` parts. Do not populate the V2 assistant `retry` field during migration; historical retry state is not
useful enough to preserve. The original retry rows remain in the V1 `part` table.
Do not emit V2 assistant content for V1 `step-start` and `step-finish` parts. Use the first available
`step-start.snapshot` as `assistant.snapshot.start` and the last available `step-finish.snapshot` as
`assistant.snapshot.end`. Continue to source finish, cost, and tokens from the assistant message itself. Ignore step
markers without snapshots.
Do not emit assistant content for standalone V1 `snapshot` or `patch` parts. If no start snapshot came from `step-start`,
use the first standalone snapshot value, then the first patch hash as a final fallback. Only `step-finish.snapshot` may
populate the end snapshot. Merge patch file lists into `assistant.snapshot.files` in first-seen order with duplicates
removed.
V2 follow-up: replace the open `SessionError.Error` string shape with a properly typed persisted error union. This is not
a blocker for the V1 migration, which should target the current storage contract.
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior. adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
For an ordinary V2 user message, take visible V1 text parts that are neither ignored nor synthetic, preserve part order,
and join their text with `"\n\n"`. Use an empty string when the message contains attachments but no ordinary text.
Ignore the optional V1 user-message `system` override. Do not create a V2 system message or preserve the override in
metadata. The original value remains in the V1 `message` row.
Ignore the optional V1 user-message `tools` map. It represented request-time tool enablement for a historical step and
must not affect future V2 execution. The original value remains in the V1 `message` row.
Ignore the optional V1 user-message `format` field and its schema. It controlled structured-output behavior for a
historical request and must not affect future V2 runs. Preserve visible assistant text normally; retain the original
format only in the V1 `message` row.
Ignore V1 user-message `summary` metadata, including title, body, and diffs. V2 user messages have no equivalent field,
and session-level summary data is already persisted separately. Retain the original summary only in the V1 `message`
row.
Map V1 `agent` parts into the V2 user message's `agents` array in part order. Preserve `name`. When the V1 part has
`source`, map its `value`, `start`, and `end` into the V2 attachment's `mention.text`, `mention.start`, and `mention.end`.
Omit `agents` when there are no agent parts.
Do not read the filesystem or network while migrating V1 file attachments. Attachment migration must be deterministic
from database contents alone. Convert persisted `data:` URLs; represent non-embedded `file:`, HTTP, and other external
URLs with deterministic text rather than fetching them. Keep the original V1 `part` rows unchanged.
For a V1 file backed by a `data:` URL, decode the URL and normalize its payload to base64 for the V2 attachment's `data`.
Preserve `mime` and optional `filename` as `name`. Use a V2 `uri` source with the original URI for a V1 resource source;
otherwise use an `inline` source. When V1 source text metadata exists, map its `value`, `start`, and `end` into the V2
attachment mention. Leave `description` unset and preserve file-part order in the V2 `files` array.
For a non-embedded V1 file, do not create a V2 file attachment. Append
`[Attachment unavailable after migration: <name-or-url> (<mime>)]` to the V2 user text in original part order, separated
by blank lines. Prefer the V1 filename, then resource URI, then part URL for the label. The original URL remains only in
the preserved V1 `part` row.
For a synthetic row split from a mixed user message, derive a generated-looking ID from the source message ID. Preserve
the source ID's 12-character timestamp component and replace its 14-character random component with a deterministic
base-62 encoding of a hash of `v1-synthetic:` plus the source message ID. If that candidate collides with an existing or
derived message ID, deterministically retry with an incrementing salt. Place the synthetic row immediately after its
source user row. Entirely synthetic messages continue to reuse their original message ID.
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
admitted compaction input ID and preserves references to the initiating message. admitted compaction input ID and preserves references to the initiating message.
@@ -219,13 +64,9 @@ serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2 retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
assistant row. assistant row.
Do not project incomplete or failed V1 compactions into `session_message`. Omit both the internal compaction user marker After rebuilding `session_message`, seed `event_sequence` with one row per migrated session. Set its watermark to that
and its paired summary assistant when no successful summary was completed. Assign final sequence numbers after filtering session's maximum backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting
so omitted compactions leave no gaps. Their source rows remain preserved in the V1 `message` and `part` tables. before migrated history. The `event` table remains empty.
After rebuilding a session's `session_message`, replace its `event_sequence` watermark with that session's maximum
backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting before migrated
history. The migrated session's prior `event` rows are removed in the same transaction.
## Drop ## Drop
@@ -233,7 +74,6 @@ Drop these pre-launch V2 tables without preserving or transforming their rows:
- `session_input` - `session_input`
- `session_context_epoch` - `session_context_epoch`
- `data_migration`
Do not transfer `session_input` rows into `session_pending`. Do not transfer `session_input` rows into `session_pending`.
@@ -263,36 +103,16 @@ schema.
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns. backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
## Execution ## Verification
Before transforming V1 rows, look for `opencode-next.db` in the data directory. This file was used by pre-launch V2 The canonical migration test should seed representative V1 sessions, messages, parts, todos, projects, accounts,
builds. Open it read-only with Bun SQLite and copy its `project`, `session`, and `session_message` rows directly into the credentials, permissions, shares, and workspaces. After migration, it should verify:
current `project`, `session_v2`, and `session_message` tables. Existing current projects and Sessions win ID collisions.
Do not copy its durable events or runtime caches; initialize each imported Session's `event_sequence` watermark from its
maximum message sequence. Commit each imported Session independently and leave the source database untouched.
The previous V2 import is part of this migration and uses the same completion marker. It needs no source-specific cursor: - Preserved rows and encoded values remain unchanged.
the destination Session row is the per-Session idempotency boundary, so a retry skips transactions that already committed. - Todo rows remain available in the unchanged `todo` table.
- `event` is empty, and stale pre-launch rows are absent from the rebuilt projections.
Store V1 backfill state in `kv`; do not retain a dedicated `data_migration` table. Store the last successfully migrated - Backfilled `session_message` rows represent the canonical V1 `message` and `part` history.
session ID under `migration.v1-v2.session.cursor` and write `migration.v1-v2.completed` with value `true` after every - Each migrated session's `event_sequence` watermark matches its maximum backfilled message sequence.
session finishes. Delete the cursor key on completion and return immediately on later calls when the completion key - Dropped tables no longer exist.
exists. - New tables exist and are empty.
- The final schema has no ungenerated changes.
Absence of the completion key means migration is required, including on a fresh database. Running the endpoint against a
database with no sessions completes immediately and writes the completion key; fresh database initialization does not
seed migration state specially.
Process sessions in stable ID order. Rebuild one session in one transaction, including its `session_message` rows,
session-level backfills, `event_sequence` watermark, and cursor update. If interrupted during a session, that transaction
rolls back and the next endpoint call retries the same session. If it committed, the next call continues after the stored
cursor. Mark the migration complete after the final session and return immediately on later calls.
Ensure the global project exists using the current platform's filesystem root as its worktree. Process every `session`
row, including archived, root, child, and empty sessions, as well as sessions whose messages are all skipped or internal.
Reassign beta and V1 Sessions whose referenced project row is missing to the global project and log a warning. Each
successfully committed session advances the cursor.
## Testing
Detailed migration test design is deferred until after the canonical migration is implemented.
+24 -88
View File
@@ -67,12 +67,6 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
}) })
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall> type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
// Intentionally omit Gemini's provider-specific `extra_content.google.thought_signature`
// extension until direct Google OpenAI-compatible routing is supported here:
// https://github.com/vercel/ai/issues/11590
// https://github.com/vercel/ai/pull/11745
// https://ai.google.dev/gemini-api/docs/thought-signatures#openai
const OpenAIChatUserContent = Schema.Union([ const OpenAIChatUserContent = Schema.Union([
Schema.Struct({ Schema.Struct({
type: Schema.Literal("text"), type: Schema.Literal("text"),
@@ -151,33 +145,22 @@ export type OpenAIChatBody = Schema.Schema.Type<typeof OpenAIChatBody>
// The event schema is one decoded SSE `data:` payload. `Framing.sse` splits the // The event schema is one decoded SSE `data:` payload. `Framing.sse` splits the
// byte stream into strings, then `Protocol.jsonEvent` decodes each string into // byte stream into strings, then `Protocol.jsonEvent` decodes each string into
// this provider-native event shape. // this provider-native event shape.
const OpenAIChatUsage = Schema.StructWithRest( const OpenAIChatUsage = Schema.Struct({
Schema.Struct({ prompt_tokens: Schema.optional(Schema.Number),
prompt_tokens: optionalNull(Schema.Number), completion_tokens: Schema.optional(Schema.Number),
completion_tokens: optionalNull(Schema.Number), total_tokens: Schema.optional(Schema.Number),
total_tokens: optionalNull(Schema.Number),
prompt_tokens_details: optionalNull( prompt_tokens_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({ Schema.Struct({
cached_tokens: optionalNull(Schema.Number), cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: optionalNull(Schema.Number), cache_write_tokens: Schema.optional(Schema.Number),
}), }),
[Schema.Record(Schema.String, Schema.Unknown)],
),
), ),
completion_tokens_details: optionalNull( completion_tokens_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({ Schema.Struct({
reasoning_tokens: optionalNull(Schema.Number), reasoning_tokens: Schema.optional(Schema.Number),
accepted_prediction_tokens: optionalNull(Schema.Number),
rejected_prediction_tokens: optionalNull(Schema.Number),
}), }),
[Schema.Record(Schema.String, Schema.Unknown)],
), ),
), })
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatToolCallDeltaFunction = Schema.Struct({ const OpenAIChatToolCallDeltaFunction = Schema.Struct({
name: optionalNull(Schema.String), name: optionalNull(Schema.String),
@@ -185,7 +168,7 @@ const OpenAIChatToolCallDeltaFunction = Schema.Struct({
}) })
const OpenAIChatToolCallDelta = Schema.Struct({ const OpenAIChatToolCallDelta = Schema.Struct({
index: optionalNull(Schema.Number), index: Schema.Number,
id: optionalNull(Schema.String), id: optionalNull(Schema.String),
function: optionalNull(OpenAIChatToolCallDeltaFunction), function: optionalNull(OpenAIChatToolCallDeltaFunction),
}) })
@@ -239,8 +222,6 @@ export interface ParserState {
readonly reasoningDetails: Array<unknown> readonly reasoningDetails: Array<unknown>
readonly reasoningDetailsObserved: boolean readonly reasoningDetailsObserved: boolean
readonly reasoningEmitted: boolean readonly reasoningEmitted: boolean
readonly latestToolIndex?: number
readonly nextToolIndex: number
} }
// ============================================================================= // =============================================================================
@@ -578,34 +559,22 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// satisfied on both sides. // satisfied on both sides.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined if (!usage) return undefined
const input = usage.prompt_tokens ?? undefined const cached = usage.prompt_tokens_details?.cached_tokens
const output = usage.completion_tokens ?? undefined const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens
const cached = usage.prompt_tokens_details?.cached_tokens ?? undefined const reasoning = usage.completion_tokens_details?.reasoning_tokens
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, ProviderShared.sumTokens(cached, cacheWrite))
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
return new Usage({ return new Usage({
inputTokens: input, inputTokens: usage.prompt_tokens,
outputTokens: output, outputTokens: usage.completion_tokens,
nonCachedInputTokens: nonCached, nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached, cacheReadInputTokens: cached,
cacheWriteInputTokens: cacheWrite, cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning, reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined), totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
providerMetadata: { openai: usage }, providerMetadata: { openai: usage },
}) })
} }
const toolIndexByID = (
tools: ParserState["tools"],
pendingTools: ParserState["pendingTools"],
id: string | undefined,
) => {
if (!id) return undefined
const entry = Object.entries({ ...pendingTools, ...tools }).find(([, tool]) => tool?.id === id)
return entry ? Number(entry[0]) : undefined
}
const reasoningDelta = ( const reasoningDelta = (
delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined, delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined,
configuredField?: string, configuredField?: string,
@@ -688,34 +657,17 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const usage = mapUsage(event.usage) ?? state.usage const usage = mapUsage(event.usage) ?? state.usage
const choice = event.choices?.[0] const choice = event.choices?.[0]
const rawFinishReason = choice?.finish_reason const finishReason = choice?.finish_reason
const finishReason = ? { normalized: mapFinishReason(choice.finish_reason), raw: choice.native_finish_reason ?? choice.finish_reason }
rawFinishReason !== undefined && rawFinishReason !== null
? { normalized: mapFinishReason(rawFinishReason), raw: choice?.native_finish_reason ?? rawFinishReason }
: state.finishReason : state.finishReason
const delta = choice?.delta const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? [] const toolDeltas = delta?.tool_calls ?? []
let tools = state.tools let tools = state.tools
let pendingTools = state.pendingTools let pendingTools = state.pendingTools
let latestToolIndex = state.latestToolIndex
let nextToolIndex = state.nextToolIndex
let lifecycle = state.lifecycle let lifecycle = state.lifecycle
const reasoning = reasoningDelta(delta, state.reasoningField) const reasoning = reasoningDelta(delta, state.reasoningField)
const hasLateContent =
Boolean(delta?.content) ||
reasoning !== undefined ||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
toolDeltas.some(
(tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments),
)
if (state.finishReason !== undefined) {
if (hasLateContent)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
return [{ ...state, usage }, events] as const
}
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined) const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta) if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
@@ -742,37 +694,24 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
} }
// Compatible providers may omit indexes. Prefer durable identity, then use for (const tool of toolDeltas) {
// batch position for parallel deltas or the latest call for sparse chunks. const current = tools[tool.index]
for (const [position, tool] of toolDeltas.entries()) { const pending = pendingTools[tool.index]
const matched = toolIndexByID(tools, pendingTools, tool.id || undefined)
const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position)
const fallbackTool = tools[fallback] ?? pendingTools[fallback]
const index =
tool.index ?? matched ??
(tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
const current = tools[index]
const pending = pendingTools[index]
const id = current?.id ?? pending?.id ?? (tool.id || undefined) const id = current?.id ?? pending?.id ?? (tool.id || undefined)
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined) const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}` const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
latestToolIndex = index
nextToolIndex = Math.max(nextToolIndex, index + 1)
if (!current && (!id || !name)) { if (!current && (!id || !name)) {
pendingTools = { pendingTools = { ...pendingTools, [tool.index]: { id: id || undefined, name: name || undefined, input: text } }
...pendingTools,
[index]: { id: id || undefined, name: name || undefined, input: text },
}
continue continue
} }
if (pending) { if (pending) {
pendingTools = { ...pendingTools } pendingTools = { ...pendingTools }
delete pendingTools[index] delete pendingTools[tool.index]
} }
const result = ToolStream.appendOrStart( const result = ToolStream.appendOrStart(
ADAPTER, ADAPTER,
tools, tools,
index, tool.index,
{ id: id || undefined, name: name || undefined, text }, { id: id || undefined, name: name || undefined, text },
"OpenAI Chat tool call delta is missing id or name", "OpenAI Chat tool call delta is missing id or name",
) )
@@ -804,8 +743,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
reasoningDetails: state.reasoningDetails, reasoningDetails: state.reasoningDetails,
reasoningDetailsObserved, reasoningDetailsObserved,
reasoningEmitted, reasoningEmitted,
latestToolIndex,
nextToolIndex,
}, },
events, events,
] as const ] as const
@@ -862,7 +799,6 @@ export const protocol = Protocol.make({
reasoningDetails: [], reasoningDetails: [],
reasoningDetailsObserved: false, reasoningDetailsObserved: false,
reasoningEmitted: false, reasoningEmitted: false,
nextToolIndex: 0,
}), }),
step, step,
onHalt: finishEvents, onHalt: finishEvents,
+23 -22
View File
@@ -5,7 +5,6 @@ import type { ProviderPackage } from "../provider-package"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses" import * as OpenAIResponses from "../protocols/openai-responses"
import { ProviderShared } from "../protocols/shared"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("azure") export const id = ProviderID.make("azure")
@@ -20,7 +19,7 @@ export type LanguageModelOptions = AzureURL &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly apiVersion?: string readonly apiVersion?: string
readonly queryParams?: Record<string, string> readonly queryParams?: Record<string, string>
readonly useDeploymentBasedUrls?: boolean readonly useCompletionUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput readonly providerOptions?: OpenAIProviderOptionsInput
} }
export type Config = LanguageModelOptions export type Config = LanguageModelOptions
@@ -30,22 +29,27 @@ export type Settings = ProviderPackage.Settings &
readonly apiKey?: string readonly apiKey?: string
readonly apiVersion?: string readonly apiVersion?: string
readonly queryParams?: Readonly<Record<string, string>> readonly queryParams?: Readonly<Record<string, string>>
readonly useDeploymentBasedUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput readonly providerOptions?: OpenAIProviderOptionsInput
} }
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai` const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1`
const responsesRoute = OpenAIResponses.route.with({ const responsesRoute = OpenAIResponses.route.with({
id: "azure-openai-responses", id: "azure-openai-responses",
provider: id, provider: id,
auth: routeAuth, auth: routeAuth,
endpoint: {
query: { "api-version": "v1" },
},
}) })
const chatRoute = OpenAIChat.route.with({ const chatRoute = OpenAIChat.route.with({
id: "azure-openai-chat", id: "azure-openai-chat",
provider: id, provider: id,
auth: routeAuth, auth: routeAuth,
endpoint: {
query: { "api-version": "v1" },
},
}) })
export const routes = [responsesRoute, chatRoute] export const routes = [responsesRoute, chatRoute]
@@ -55,7 +59,7 @@ const defaults = (input: Config) => {
apiKey: _, apiKey: _,
apiVersion: _apiVersion, apiVersion: _apiVersion,
resourceName: _resourceName, resourceName: _resourceName,
useDeploymentBasedUrls: _useDeploymentBasedUrls, useCompletionUrls: _useCompletionUrls,
baseURL: _baseURL, baseURL: _baseURL,
queryParams: _queryParams, queryParams: _queryParams,
...rest ...rest
@@ -76,39 +80,37 @@ const auth = (input: Config) => {
) )
} }
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config, modelID: string | ModelID) => const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) =>
route.with({ route.with({
auth: auth(input), auth: auth(input),
endpoint: endpoint(input, modelID), endpoint: {
// AtLeastOne guarantees at least one is set; baseURL wins if both are.
baseURL: input.baseURL ?? resourceBaseURL(input.resourceName!),
query: {
...(input.apiVersion ? { "api-version": input.apiVersion } : {}),
...input.queryParams,
},
},
}) })
function endpoint(input: Config, modelID: string | ModelID) {
const baseURL = ProviderShared.trimBaseUrl(input.baseURL ?? resourceBaseURL(input.resourceName!))
const query = { "api-version": input.apiVersion ?? "v1", ...input.queryParams }
if (input.useDeploymentBasedUrls) return { baseURL: `${baseURL}/deployments/${modelID}`, query }
if (input.baseURL !== undefined && !new URL(input.baseURL).hostname.endsWith(".openai.azure.com")) {
return { baseURL, query: input.queryParams }
}
return { baseURL: `${baseURL}/v1`, query }
}
export const configure = (input: Config) => { export const configure = (input: Config) => {
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input) const modelDefaults = defaults(input)
const responses = (modelID: string | ModelID) => const responses = (modelID: string | ModelID) =>
configuredRoute(responsesRoute, input, modelID) configuredResponsesRoute
.with(withOpenAIOptions(modelID, modelDefaults)) .with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID }) .model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => const chat = (modelID: string | ModelID) =>
configuredRoute(chatRoute, input, modelID) configuredChatRoute
.with(withOpenAIOptions(modelID, modelDefaults)) .with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID }) .model<OpenAIProviderOptionsInput>({ id: modelID })
return { return {
id, id,
model: responses, model: (modelID: string | ModelID) => (input.useCompletionUrls === true ? chat(modelID) : responses(modelID)),
responses, responses,
chat, chat,
configure, configure,
@@ -129,7 +131,6 @@ const config = (settings: Settings): Config => {
limits: settings.limits, limits: settings.limits,
providerOptions: settings.providerOptions, providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
} }
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL } if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName } if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
@@ -15,7 +15,8 @@ export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
const VERSION = "vertex-2023-10-16" as const const VERSION = "vertex-2023-10-16" as const
export const id = ProviderID.make("google-vertex") // models.dev uses this provider id even though the API contract is Anthropic Messages.
export const id = ProviderID.make("google-vertex-anthropic")
export type Config = RouteDefaultsInput & export type Config = RouteDefaultsInput &
GoogleVertexShared.OAuthOptions & { GoogleVertexShared.OAuthOptions & {
-1
View File
@@ -178,7 +178,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility), toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String), reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility), maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
}) {} }) {}
export namespace LanguageModelCompatibility { export namespace LanguageModelCompatibility {
+2 -2
View File
@@ -102,7 +102,7 @@ describe("llm constructors", () => {
const updated = LanguageModel.update(base, { const updated = LanguageModel.update(base, {
route: responsesRoute, route: responsesRoute,
defaults: { generation: { maxTokens: 20 } }, defaults: { generation: { maxTokens: 20 } },
compatibility: { toolSchema: "gemini", requireFinishReason: false }, compatibility: { toolSchema: "gemini" },
}) })
const updatedInput = LanguageModel.input(updated) const updatedInput = LanguageModel.input(updated)
@@ -110,7 +110,7 @@ describe("llm constructors", () => {
expect(String(updated.id)).toBe("fake-model") expect(String(updated.id)).toBe("fake-model")
expect(updated.route).toBe(responsesRoute) expect(updated.route).toBe(responsesRoute)
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 }) expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
expect(updated.compatibility).toEqual({ toolSchema: "gemini", requireFinishReason: false }) expect(updated.compatibility).toEqual({ toolSchema: "gemini" })
expect(updatedInput.defaults).toBe(updated.defaults) expect(updatedInput.defaults).toBe(updated.defaults)
expect(updatedInput.compatibility).toBe(updated.compatibility) expect(updatedInput.compatibility).toBe(updated.compatibility)
expect(String(updatedInput.provider)).toBe("fake") expect(String(updatedInput.provider)).toBe("fake")
-21
View File
@@ -209,27 +209,6 @@ describe("provider package entrypoints", () => {
expect(chat.route.id).toBe("azure-openai-chat") expect(chat.route.id).toBe("azure-openai-chat")
}) })
test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => {
const Azure = await import("@opencode-ai/ai/providers/azure")
const deployment = Azure.model("custom-deployment", {
apiKey: "fixture",
resourceName: "opencode-test",
apiVersion: "2025-01-01-preview",
useDeploymentBasedUrls: true,
})
const gateway = Azure.model("gateway-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/azure/",
})
expect(deployment.route.endpoint).toMatchObject({
baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment",
query: { "api-version": "2025-01-01-preview" },
})
expect(gateway.route.endpoint.baseURL).toBe("https://gateway.example/azure")
expect(gateway.route.endpoint.query).toBeUndefined()
})
test("maps Google package settings onto the Gemini model", async () => { test("maps Google package settings onto the Gemini model", async () => {
const Google = await import("@opencode-ai/ai/providers/google") const Google = await import("@opencode-ai/ai/providers/google")
const selected = Google.model("gemini-2.5-flash", { const selected = Google.model("gemini-2.5-flash", {
@@ -56,14 +56,13 @@ describe("Google Vertex providers", () => {
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () => it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
Effect.gen(function* () { Effect.gen(function* () {
const model = GoogleVertexMessages.configure({ const response = yield* LLMClient.generate(
LLM.request({
model: GoogleVertexMessages.configure({
accessToken: "vertex-token", accessToken: "vertex-token",
location: "eu", location: "eu",
project: "vertex-project", project: "vertex-project",
}).model("claude-sonnet-4-6") }).model("claude-sonnet-4-6"),
const response = yield* LLMClient.generate(
LLM.request({
model,
prompt: "Say hello.", prompt: "Say hello.",
}), }),
).pipe( ).pipe(
@@ -98,7 +97,6 @@ describe("Google Vertex providers", () => {
), ),
) )
expect(model.provider).toBe("google-vertex")
expect(response.text).toBe("Hello.") expect(response.text).toBe("Hello.")
}), }),
) )
@@ -7,7 +7,7 @@ import { compileRequest } from "../../src/route/client"
import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat" import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
import { it } from "../lib/effect" import { it } from "../lib/effect"
import { dynamicResponse, fixedResponse } from "../lib/http" import { dynamicResponse } from "../lib/http"
import { sseEvents } from "../lib/sse" import { sseEvents } from "../lib/sse"
const Json = Schema.fromJsonString(Schema.Unknown) const Json = Schema.fromJsonString(Schema.Unknown)
@@ -253,106 +253,4 @@ describe("OpenAI-compatible Chat route", () => {
}) })
}), }),
) )
it.effect("accepts nullable usage and preserves provider fields", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ content: "Hello" }),
deltaChunk({}, "stop"),
usageChunk({
prompt_tokens: null,
completion_tokens: null,
total_tokens: null,
prompt_tokens_details: { cached_tokens: null, vendor_cache_tokens: 3 },
completion_tokens_details: {
reasoning_tokens: null,
accepted_prediction_tokens: null,
rejected_prediction_tokens: null,
},
cost: "0.001",
}),
),
),
),
)
expect(response.usage).toMatchObject({
inputTokens: undefined,
outputTokens: undefined,
totalTokens: undefined,
providerMetadata: {
openai: {
prompt_tokens: null,
completion_tokens: null,
total_tokens: null,
prompt_tokens_details: { cached_tokens: null, vendor_cache_tokens: 3 },
cost: "0.001",
},
},
})
}),
)
it.effect("assembles indexless parallel tool calls across sparse chunks", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } })],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({
tool_calls: [
{ id: "call_paris", function: { name: "weather", arguments: '{"city":"' } },
{ index: null, id: "call_london", function: { name: "weather", arguments: '{"city":"' } },
],
}),
deltaChunk({ tool_calls: [{ function: { arguments: 'London"}' } }] }),
deltaChunk({ tool_calls: [{ id: "call_paris", function: { arguments: 'Paris"}' } }] }),
deltaChunk({}, "tool_calls"),
),
),
),
)
expect(response.toolCalls).toMatchObject([
{ id: "call_paris", name: "weather", input: { city: "Paris" } },
{ id: "call_london", name: "weather", input: { city: "London" } },
])
}),
)
it.effect("treats an empty finish reason as terminal", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "")))),
)
expect(response.finishReason).toEqual({ normalized: "unknown", raw: "" })
}),
)
it.effect("rejects content after a terminal chunk", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ content: "Hello" }),
deltaChunk({}, "stop"),
deltaChunk({ tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{}" } }] }),
),
),
),
Effect.flip,
)
expect(error.message).toContain("OpenAI Chat received content after the finish reason")
}),
)
}) })
+220
View File
@@ -0,0 +1,220 @@
# V1 API Migration Checklist
The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
## Events
- [x] Replace `GET /global/event` with `GET /api/event`.
- `src/context/server-sdk.tsx`
- [x] Reduce current granular session and message events into the existing app projections.
- `src/context/server-session-v2-reducer.ts`
- `src/context/server-session.ts`
- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
- `src/context/global-sync/event-reducer.ts`
- `src/context/server-session.ts`
- `src/context/notification.tsx`
- `src/pages/session/usage-exceeded-dialogs.tsx`
- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
- `src/context/global-sync/event-reducer.ts`
- `src/context/server-session.ts`
- [x] Adapt current permission and question events to the existing request model.
- `src/context/global-sync/event-reducer.ts`
- `src/context/permission.tsx`
- [x] Consume current file watcher events.
- `src/context/file.tsx`
- [x] Consume current VCS events.
- `src/context/global-sync/event-reducer.ts`
- `src/pages/session.tsx`
- [x] Consume current `pty.exited` events.
- `src/context/terminal.tsx`
- [ ] Migrate LSP and reference events.
- `src/context/global-sync/event-reducer.ts`
## Sessions
- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
- `src/context/server-sync.tsx`
- [x] Migrate session listing from `GET /session`.
- `src/context/server-sync.tsx`
- `src/context/directory-sync.ts`
- `src/pages/layout.tsx`
- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
- `src/components/titlebar.tsx`
- [x] Migrate session updates from `PATCH /session/:sessionID`.
- `src/context/directory-sync.ts`
- `src/context/layout.tsx`
- `src/pages/home.tsx`
- `src/pages/layout.tsx`
- `src/pages/session/timeline/message-timeline.tsx`
- `src/components/titlebar-tab-nav.tsx`
- Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
- [x] Migrate session deletion from `DELETE /session/:sessionID`.
- `src/pages/session/timeline/message-timeline.tsx`
- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
- Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
- [x] Migrate abort from `POST /session/:sessionID/abort`.
- `src/components/prompt-input/submit.ts`
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session.tsx`
- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session.tsx`
- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
- `src/pages/session/use-session-commands.tsx`
- [x] Migrate slash commands from `POST /session/:sessionID/command`.
- `src/components/prompt-input/submit.ts`
- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
- `src/components/prompt-input/submit.ts`
- [x] Migrate session fork from `POST /session/:sessionID/fork`.
- `src/components/dialog-fork.tsx`
- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session/timeline/message-timeline.tsx`
- Blocked: the current API has no sharing contract or implementation.
## Session Compatibility Fallbacks
These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
- `src/context/server-session.ts`
- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
- `src/context/server-session.ts`
- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
- `src/context/server-session.ts`
## Filesystem
- [ ] Migrate file listing from `GET /file`.
- `src/context/file.tsx`
- [ ] Migrate file reads from `GET /file/content`.
- `src/context/file.tsx`
- `src/pages/session/review-tab.tsx`
- `src/pages/session/v2/review-panel-v2.tsx`
- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
- `src/context/global-sync/bootstrap.ts`
- `src/components/dialog-select-directory.tsx`
- `src/components/dialog-select-directory-v2.tsx`
## Projects And Worktrees
- [x] Migrate project listing from `GET /project` to `GET /api/project`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate Git initialization from `POST /project/git/init`.
- `src/pages/session.tsx`
- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
- `src/context/layout.tsx`
- `src/components/edit-project.ts`
- `src/pages/layout.tsx`
- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
- `src/pages/layout.tsx`
- `src/components/prompt-input/submit.ts`
- Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
- [ ] Migrate instance disposal from `POST /instance/dispose`.
- `src/pages/layout.tsx`
## VCS
- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
- `src/pages/session.tsx`
- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
- `src/pages/layout.tsx`
## Configuration And Authentication
- [ ] Migrate global configuration reads from `GET /global/config`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate directory configuration reads from `GET /config`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate global configuration updates from `PATCH /global/config`.
- `src/context/server-sync.tsx`
- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
- `src/components/dialog-connect-provider.tsx`
- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
- `src/components/dialog-connect-provider.tsx`
- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
- Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
- `src/components/dialog-connect-provider.tsx`
- `src/components/dialog-custom-provider.tsx`
- `src/components/settings-providers.tsx`
- `src/components/settings-v2/providers.tsx`
- [ ] Migrate global disposal from `POST /global/dispose`.
- `src/components/dialog-connect-provider.tsx`
- `src/components/settings-providers.tsx`
- `src/components/settings-v2/providers.tsx`
## Permissions And Questions
- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
- `src/context/global-sync/bootstrap.ts`
- `src/context/permission.tsx`
- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
- `src/context/permission.tsx`
- `src/pages/session/composer/session-composer-state.ts`
- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
- `src/pages/session/composer/session-question-dock.tsx`
## Commands, MCP, LSP, And References
- [x] Migrate command listing from `GET /command` to `GET /api/command`.
- `src/context/global-sync/bootstrap.ts`
- `src/context/server-sync.tsx`
- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
- `src/context/server-sync.tsx`
- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
- `src/context/server-sync.tsx`
- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
- `src/context/server-sync.tsx`
- [ ] Migrate LSP status from `GET /lsp`.
- `src/context/server-sync.tsx`
- [x] Move `GET /api/reference` off the legacy generated SDK transport.
- `src/context/global-sync/bootstrap.ts`
## Search
- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
- `src/components/command-palette.ts`
- `src/components/dialog-command-palette-v2.tsx`
## PTY And Terminal
- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
- `src/context/terminal.tsx`
- `src/components/terminal.tsx`
- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
- `src/components/settings-general.tsx`
- `src/components/settings-v2/general.tsx`
- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
- `src/components/terminal.tsx`
- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
- `src/components/terminal.tsx`
## Legacy Types And Adapters
These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
- [ ] Replace the current-session-to-legacy-session adapter.
- `src/utils/session.ts`
- [ ] Replace the current-message-to-legacy-message-and-part adapter.
- `src/utils/session-message.ts`
- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
- `src/context/global-sync/utils.ts`
- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
- `package.json`
## Test Infrastructure
- [ ] Replace V1 endpoint mocks with current API mocks.
- `e2e/utils/mock-server.ts`
- [x] Replace `/global/event` and `/event` interception with current event transport handling.
- `e2e/utils/sse-transport.ts`
- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
- `e2e/performance/timeline-stability/fixture.ts`
- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.
@@ -45,6 +45,10 @@ describe("timeline fixture validation", () => {
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/) expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1) expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
}) })
test("uses the projected tool ID as its call ID", () => {
expect(toolPart("call_1", "read", "running", {})).toMatchObject({ id: "call_1", callID: "call_1" })
})
}) })
if (false) { if (false) {
@@ -2,8 +2,15 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event" import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
import { SessionV1 } from "@opencode-ai/schema/session-v1" import { SessionV1 } from "@opencode-ai/schema/session-v1"
import type { SessionInfo, SessionStatus } from "@opencode-ai/client/promise" import type {
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types" AssistantMessage,
Message,
Part,
ToolPart,
ToolState,
UserMessage,
} from "../../../src/types"
import type { SessionV1Info, SessionStatus } from "@opencode-ai/client/promise"
import { expect, type Page } from "@playwright/test" import { expect, type Page } from "@playwright/test"
import { Schema } from "effect" import { Schema } from "effect"
import { mockOpenCodeServer } from "../../utils/mock-server" import { mockOpenCodeServer } from "../../utils/mock-server"
@@ -18,7 +25,7 @@ export const assistantID = "msg_1001_timeline_assistant"
export const title = "Timeline visual stability" export const title = "Timeline visual stability"
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type Session = SessionInfo type Session = SessionV1Info
type GlobalEvent = { type GlobalEvent = {
directory: string directory: string
project?: string project?: string
@@ -530,11 +537,11 @@ export function project() {
export function session(input: Partial<Session> = {}): Session { export function session(input: Partial<Session> = {}): Session {
return { return {
id: sessionID, id: sessionID,
slug: "timeline-stability",
projectID, projectID,
location: { directory }, directory,
title, title,
cost: 0, version: "dev",
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1700000000000, updated: 1700000000000 }, time: { created: 1700000000000, updated: 1700000000000 },
...input, ...input,
} }
@@ -178,7 +178,6 @@ test("shows all and expands historical diff summary without overlap", async ({ p
summary: { summary: {
diffs: Array.from({ length: 12 }, (_, index) => ({ diffs: Array.from({ length: 12 }, (_, index) => ({
file: `src/diff-${index}.ts`, file: `src/diff-${index}.ts`,
status: "modified",
additions: 1, additions: 1,
deletions: 1, deletions: 1,
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`, patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
@@ -1,18 +1,19 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
const draftID = "draft_removed_layout_preference" const draftID = "draft_legacy_new_session"
const directory = "C:/OpenCode/RemovedLayoutPreference" const directory = "C:/OpenCode/LegacyNewSession"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("ignores persisted old layout preferences when opening drafts", async ({ page }) => { test("redirects a draft to the legacy new-session route", async ({ page }) => {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
directory, directory,
project: { project: {
id: "proj_removed_layout_preference", id: "proj_legacy_new_session",
worktree: directory, worktree: directory,
vcs: "git", vcs: "git",
name: "removed-layout-preference", name: "legacy-new-session",
time: { created: 1700000000000, updated: 1700000000000 }, time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [], sandboxes: [],
}, },
@@ -23,6 +24,7 @@ test("ignores persisted old layout preferences when opening drafts", async ({ pa
await page.addInitScript( await page.addInitScript(
({ directory, draftID, server }) => { ({ directory, draftID, server }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } })) localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
localStorage.setItem( localStorage.setItem(
"opencode.window.browser.dat:tabs", "opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]), JSON.stringify([{ type: "draft", draftID, server, directory }]),
@@ -33,7 +35,7 @@ test("ignores persisted old layout preferences when opening drafts", async ({ pa
await page.goto(`/new-session?draftId=${draftID}`) await page.goto(`/new-session?draftId=${draftID}`)
await expect(page).toHaveURL(`/new-session?draftId=${draftID}`) await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
await expect(page.locator("body")).toHaveAttribute("data-new-layout", "") await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
await expect(page.getByRole("textbox", { name: "Prompt" })).toBeVisible() await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
}) })
@@ -14,8 +14,8 @@ const projectID = "proj_context_resize_regression"
const sessionID = "ses_context_resize_regression" const sessionID = "ses_context_resize_regression"
const title = "Context resize regression" const title = "Context resize regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"] const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
const followingTextID = `${id("msg_assistant", 10)}:text:0` const followingTextID = "prt_0104_text"
type Message = { type Message = {
info: Record<string, unknown> & { id: string; role: "user" | "assistant" } info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
@@ -295,7 +295,7 @@ function contextTool(
sessionID, sessionID,
messageID, messageID,
type: "tool", type: "tool",
callID: partID, callID: `call_${partID}`,
tool, tool,
state: { state: {
status, status,
@@ -23,7 +23,7 @@ type EventPayload = {
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" }) test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
test("animates todo opening without replaying it across session tabs", async ({ page }) => { test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
test.setTimeout(90_000) test.setTimeout(90_000)
const events: EventPayload[] = [] const events: EventPayload[] = []
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] } const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
@@ -86,8 +86,28 @@ test("animates todo opening without replaying it across session tabs", async ({
await switchSession(page, otherID, otherTitle) await switchSession(page, otherID, otherTitle)
await expect(dock).toHaveCount(0) 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 switchSession(page, sourceID, sourceTitle)
await expect(dock).toHaveCount(0) await expect(dock).toHaveCount(0)
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
}) })
function session(id: string, title: string, created: number) { function session(id: string, title: string, created: number) {
@@ -32,7 +32,7 @@ test("keeps the terminal session alive when switching session tabs in a workspac
const connection = new URL(connections[0]!) const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`) expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
expect(connection.searchParams.get("location[directory]")).toBe(directory) expect(connection.searchParams.get("location[directory]")).toBe(directory)
expect(connection.searchParams.get("ticket")).toBeNull() expect(connection.searchParams.get("ticket")).toBe("e2e-ticket")
await writeProbe(page) await writeProbe(page)
await switchTab(page, titleB) await switchTab(page, titleB)
+3 -2
View File
@@ -80,8 +80,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
) )
} }
if (path === "/global/health") if (path === "/global/health")
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true }) return config.protocol === "v2" ? json(route, {}) : json(route, { healthy: true })
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 }) if (path === "/api/health" && config.protocol === "v2")
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
if (path === "/provider") return json(route, providerConfig(config)) if (path === "/provider") return json(route, providerConfig(config))
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {}) if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
+14 -2
View File
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
export type SseConnectionRecord = { export type SseConnectionRecord = {
id: number id: number
url: string url: string
path: "/api/event" path: "/global/event" | "/event" | "/api/event"
headers: Record<string, string> headers: Record<string, string>
openedAt: number openedAt: number
endedAt?: number endedAt?: number
@@ -174,7 +174,10 @@ export async function installSseTransport<T>(
const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init) const request = new Request(input, init)
const url = new URL(request.url) const url = new URL(request.url)
if (url.origin !== server || url.pathname !== "/api/event") if (
url.origin !== server ||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
)
return originalFetch(request) return originalFetch(request)
const id = ++nextConnectionID const id = ++nextConnectionID
@@ -190,9 +193,18 @@ export async function installSseTransport<T>(
record.controller = controller record.controller = controller
connections.push(record) connections.push(record)
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
if (url.pathname === "/api/event")
controller.enqueue( controller.enqueue(
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
) )
if (url.pathname === "/global/event")
controller.enqueue(
encoder.encode(
frame({
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
}),
),
)
request.signal.addEventListener( request.signal.addEventListener(
"abort", "abort",
() => { () => {
+1 -1
View File
@@ -20,7 +20,7 @@
<meta property="twitter:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script> <script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
</head> </head>
<body data-new-layout class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep"> <body class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div> <div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
<script src="/src/entry.tsx" type="module"></script> <script src="/src/entry.tsx" type="module"></script>
+1 -1
View File
@@ -40,7 +40,6 @@
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"happy-dom": "20.11.1",
"tw-animate-css": "1.4.0", "tw-animate-css": "1.4.0",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
@@ -57,6 +56,7 @@
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*", "@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
+166 -31
View File
@@ -14,11 +14,14 @@ import {
Navigate, Navigate,
Route, Route,
Router, Router,
useLocation,
useNavigate,
useParams, useParams,
useSearchParams, useSearchParams,
} from "@solidjs/router" } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect" import { Effect } from "effect"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { import {
type Component, type Component,
createEffect, createEffect,
@@ -40,7 +43,7 @@ import { CommandProvider, useCommand, type CommandOption } from "@/context/comma
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk" import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync" import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global" import { GlobalProvider, useGlobal } 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"
@@ -51,36 +54,58 @@ import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
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"
import { SettingsProvider } from "@/context/settings" import { SettingsProvider, useSettings } from "@/context/settings"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider } from "@/context/sdk" import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context" import { WslServersProvider } from "@/wsl/context"
import { DirectoryDataProvider } from "@/pages/directory-layout" import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout" import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { decode64 } from "@/utils/base64" import { createSessionLineage } from "@/pages/session/session-lineage"
import { TargetSessionRouteContent } from "@/pages/session" import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { Home } from "@/pages/home" import { NewHome } from "@/pages/home"
import { LegacyHome } from "@/pages/home/legacy-home"
const NewSession = lazy(() => import("@/pages/new-session")) const NewSession = lazy(() => import("@/pages/new-session"))
const DirectoryDraftRedirect = () => { const SessionRoute = () => {
const settings = useSettings()
const params = useParams() const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>() const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
const sdk = useSDK()
const server = useServer() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
if (params.id && settings.general.newLayoutDesigns()) {
const sessionID = 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)
// is replaced by a draft at /new-session?draftId=…
createEffect(() => { createEffect(() => {
if (search.draftId || !tabs.ready()) return if (!settings.general.newLayoutDesigns()) return
const directory = decode64(params.dir) if (params.id || search.draftId) return
if (!directory) return if (!tabs.ready() || !sdk().directory) return
tabs.newDraft({ server: server.key, directory }, search.prompt) tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
}) })
return null return (
<SessionRouteErrorBoundary sessionID={params.id}>
<SessionPage />
</SessionRouteErrorBoundary>
)
} }
function TargetServerRoute(props: ParentProps) { function TargetServerRoute(props: ParentProps) {
@@ -92,7 +117,9 @@ function TargetServerRoute(props: ParentProps) {
}) })
return ( return (
// Owns the server-identity remount. Session changes must not remount this subtree. // Owns the server-identity remount. Session changes must NOT remount this
// subtree (SessionRouteErrorBoundary resets and createSessionLineage
// re-resolves reactively instead); both rely on this key for server changes.
<Show when={requireServerKey(params.serverKey)} keyed> <Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn}> <ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider> <ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
@@ -107,6 +134,35 @@ const TargetSessionRoute = () => (
</TargetServerRoute> </TargetServerRoute>
) )
function LegacyTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
return (
<TargetServerRoute>
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
<LegacyTargetSessionRedirect />
</SessionRouteErrorBoundary>
</TargetServerRoute>
)
}
function LegacyTargetSessionRedirect() {
const params = useParams<{ id: string }>()
const navigate = useNavigate()
const sync = useServerSync()
const current = createSessionLineage(
() => params.id,
() => sync().session.lineage,
)
createEffect(() => {
const directory = current()?.session.directory
if (!directory) return
navigate(legacySessionHref(directory, params.id), { replace: true })
})
return null
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell for that server. // server via ServerKey, then provide the server-scoped shell for that server.
function SelectedServerProviders(props: ParentProps) { function SelectedServerProviders(props: ParentProps) {
@@ -119,8 +175,17 @@ function SelectedServerProviders(props: ParentProps) {
) )
} }
function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
return (
<SelectedServerProviders>
<LegacyServerScopedShell serverScoped={props.serverScoped}>{props.children}</LegacyServerScopedShell>
</SelectedServerProviders>
)
}
function DraftRoute() { function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const settings = useSettings()
const tabs = useTabs() const tabs = useTabs()
return ( return (
<Show when={tabs.ready()}> <Show when={tabs.ready()}>
@@ -129,7 +194,14 @@ function DraftRoute() {
keyed keyed
fallback={<Navigate href="/" />} fallback={<Navigate href="/" />}
> >
{(draft) => <ResolvedDraftRoute draft={draft} />} {(draft) => (
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
>
<ResolvedDraftRoute draft={draft} />
</Show>
)}
</Show> </Show>
</Show> </Show>
) )
@@ -165,6 +237,30 @@ function UiI18nBridge(props: ParentProps) {
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider> return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
} }
function LayoutCompatibility(props: ParentProps) {
const global = useGlobal()
const navigate = useNavigate()
const server = useServer()
const settings = useSettings()
createEffect(() => {
if (settings.general.newLayoutDesigns()) return
const current = server.current
if (!current) return
const protocol = global.ensureServerCtx(current).sdk.protocolKind()
if (protocol !== "v2") return
const next = global.servers.list().find((s) => {
if (ServerConnection.key(s) === ServerConnection.key(current)) return false
return global.ensureServerCtx(s).sdk.protocolKind() !== "v2"
})
if (!next) return
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(next)))
})
return <>{props.children}</>
}
declare global { declare global {
interface Window { interface Window {
__OPENCODE__?: { __OPENCODE__?: {
@@ -191,11 +287,17 @@ function QueryProvider(props: ParentProps) {
} }
function BodyDesignClass() { function BodyDesignClass() {
const settings = useSettings()
createRenderEffect(() => { createRenderEffect(() => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
document.body.toggleAttribute("data-new-layout", true)
document.body.classList.remove("text-12-regular") const enabled = settings.general.newLayoutDesigns()
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]") document.body.toggleAttribute("data-new-layout", enabled)
document.body.classList.toggle("text-12-regular", !enabled)
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
document.body.classList.toggle("text-[13px]", enabled)
document.body.classList.toggle("font-[440]", enabled)
}) })
return null return null
@@ -238,6 +340,7 @@ function DesktopCommands() {
return null return null
} }
// Server-scoped providers shared by the legacy shell and the top-level new shell.
type ServerScopedShellProps = ParentProps<{ type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined directory?: () => string | undefined
serverScoped?: JSX.Element serverScoped?: JSX.Element
@@ -252,11 +355,19 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
) )
} }
function AppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { function LegacyServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
<LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders>
)
}
function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
return ( return (
<SelectedServerProviders> <SelectedServerProviders>
<ServerScopedProviders serverScoped={props.serverScoped}> <ServerScopedProviders serverScoped={props.serverScoped}>
<Layout>{props.children}</Layout> <NewLayout>{props.children}</NewLayout>
</ServerScopedProviders> </ServerScopedProviders>
</SelectedServerProviders> </SelectedServerProviders>
) )
@@ -445,7 +556,7 @@ export function AppInterface(props: {
startup?: Promise<void> startup?: Promise<void>
serverScoped?: JSX.Element serverScoped?: JSX.Element
}) { }) {
// The visual layout lives in the router root so it remains mounted across // The visual new layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data // route changes. Draft and session routes override only their server-bound data
// providers beneath it. // providers beneath it.
const ServerShell = (shellProps: ParentProps) => ( const ServerShell = (shellProps: ParentProps) => (
@@ -466,6 +577,7 @@ export function AppInterface(props: {
<GlobalProvider> <GlobalProvider>
<SettingsProvider> <SettingsProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}> <ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
<Dynamic <Dynamic
component={props.router ?? Router} component={props.router ?? Router}
root={(routerProps) => ( root={(routerProps) => (
@@ -473,15 +585,18 @@ export function AppInterface(props: {
<PermissionProvider> <PermissionProvider>
<NotificationProvider> <NotificationProvider>
<ServerShell> <ServerShell>
<AppLayout serverScoped={props.serverScoped}>{routerProps.children}</AppLayout> <Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell> </ServerShell>
</NotificationProvider> </NotificationProvider>
</PermissionProvider> </PermissionProvider>
</TabsProvider> </TabsProvider>
)} )}
> >
<Routes /> <Routes serverScoped={props.serverScoped} />
</Dynamic> </Dynamic>
</Show>
</ConnectionGate> </ConnectionGate>
</SettingsProvider> </SettingsProvider>
</GlobalProvider> </GlobalProvider>
@@ -489,20 +604,40 @@ export function AppInterface(props: {
) )
} }
function Routes() { function Routes(props: { serverScoped?: JSX.Element }) {
const settings = useSettings()
return ( return (
<> <>
<Route path="/" component={Home} /> <Route
<Route path="/:dir" component={DirectoryDraftRedirect} /> component={(routeProps) => (
<Route path="/:dir/session" component={DirectoryDraftRedirect} /> <LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
<Route path="/:dir/session/:id" component={LegacySessionRedirect} /> )}
>
<Show when={!settings.general.newLayoutDesigns()}>
{
<>
<Route path="/" component={LegacyHome} />
<Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
</>
}
</Show>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Route>
<Show when={settings.general.newLayoutDesigns()}>
<Route path="/" component={NewHome} />
<Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} /> <Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</Show>
<Route path="/new-session" component={DraftRoute} /> <Route path="/new-session" component={DraftRoute} />
</> </>
) )
} }
function LegacySessionRedirect() { function NewLayoutLegacySessionRedirect() {
const server = useServer() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

@@ -14,6 +14,7 @@ import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers" import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { normalizeSessionInfo } from "@/utils/session"
export type CommandPaletteEntry = { export type CommandPaletteEntry = {
id: string id: string
@@ -145,7 +146,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
server: ServerConnection.key(serverSDK.server), server: ServerConnection.key(serverSDK.server),
opened: serverCtx.projects.list, opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project, stored: () => serverCtx.sync.data.project,
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }), load: (search, signal) => serverSDK.currentApi.session.list({ parentID: null, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"), untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"), category: () => language.t("command.category.session"),
}) })
@@ -256,6 +257,7 @@ export function createServerSessionEntries(props: {
.load(search, current.signal) .load(search, current.signal)
.then((result) => .then((result) =>
result.data result.data
.map(normalizeSessionInfo)
.filter((session) => !session.time.archived) .filter((session) => !session.time.archived)
.map((session) => { .map((session) => {
const project = const project =
@@ -264,9 +266,9 @@ export function createServerSessionEntries(props: {
id: `session:${props.server}:${session.id}`, id: `session:${props.server}:${session.id}`,
type: "session" as const, type: "session" as const,
title: session.title || props.untitled(), title: session.title || props.untitled(),
description: project ? displayName(project) : getFilename(session.location.directory), description: project ? displayName(project) : getFilename(session.directory),
category: props.category(), category: props.category(),
directory: session.location.directory, directory: session.directory,
sessionID: session.id, sessionID: session.id,
server: props.server, server: props.server,
project, project,
@@ -79,7 +79,7 @@ export function DialogHomeCommandPaletteV2(props: {
server: ServerConnection.key(props.server), server: ServerConnection.key(props.server),
opened: serverCtx.projects.list, opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project, stored: () => serverCtx.sync.data.project,
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }), load: (search, signal) => serverCtx.sdk.currentApi.session.list({ parentID: null, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"), untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"), category: () => language.t("command.category.session"),
}) })
@@ -418,7 +418,7 @@ function ProviderConnection(props: {
() => ({ provider: props.provider, directory: directory() }), () => ({ provider: props.provider, directory: directory() }),
(input) => (input) =>
serverSDK() serverSDK()
.api.integration.get({ .currentApi.integration.get({
integrationID: input.provider, integrationID: input.provider,
location: input.directory ? { directory: input.directory } : undefined, location: input.directory ? { directory: input.directory } : undefined,
}) })
@@ -547,7 +547,7 @@ function ProviderConnection(props: {
} }
dispatch({ type: "auth.pending" }) dispatch({ type: "auth.pending" })
await serverSDK() await serverSDK()
.api.integration.oauth.connect({ .currentApi.integration.oauth.connect({
integrationID: props.provider, integrationID: props.provider,
methodID: method.id, methodID: method.id,
inputs: inputs ?? {}, inputs: inputs ?? {},
@@ -816,7 +816,7 @@ function ProviderConnection(props: {
} }
setFormStore("error", undefined) setFormStore("error", undefined)
await serverSDK().api.integration.connect.key({ await serverSDK().currentApi.integration.connect.key({
integrationID: props.provider, integrationID: props.provider,
location: location(), location: location(),
key: apiKey, key: apiKey,
@@ -947,7 +947,7 @@ function ProviderConnection(props: {
setFormStore("error", undefined) setFormStore("error", undefined)
const result = await serverSDK() const result = await serverSDK()
.api.integration.oauth.complete({ .currentApi.integration.oauth.complete({
integrationID: props.provider, integrationID: props.provider,
attemptID: store.authorization!.attemptID, attemptID: store.authorization!.attemptID,
location: location(), location: location(),
@@ -1044,7 +1044,7 @@ function ProviderConnection(props: {
const authorization = store.authorization const authorization = store.authorization
if (!authorization || !alive.value) return if (!authorization || !alive.value) return
const result = await serverSDK() const result = await serverSDK()
.api.integration.oauth.status({ .currentApi.integration.oauth.status({
integrationID: props.provider, integrationID: props.provider,
attemptID: authorization.attemptID, attemptID: authorization.attemptID,
location: location(), location: location(),
@@ -130,9 +130,26 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
} }
const saveMutation = useMutation(() => ({ const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>): Promise<typeof result> => { mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
// TODO: Restore custom providers when V2 exposes config and arbitrary credential APIs. if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
throw new Error(`Custom provider ${result.providerID} is unavailable`) const disabledProviders = serverSync().data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
if (result.key) {
await serverSDK().legacy.auth.set({
providerID: result.providerID,
auth: {
type: "api",
key: result.key,
},
})
}
await serverSync().updateConfig({
provider: { [result.providerID]: result.config },
disabled_providers: nextDisabled,
})
return result
}, },
onSuccess: (result) => { onSuccess: (result) => {
dialog.close() dialog.close()
@@ -146,7 +146,7 @@ export function DialogEditProjectV2(props: { project: LocalProject; server: Serv
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}> <ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")} {language.t("common.cancel")}
</ButtonV2> </ButtonV2>
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}> <ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")} {model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</ButtonV2> </ButtonV2>
</DialogFooter> </DialogFooter>
@@ -0,0 +1,170 @@
import { Button } from "@opencode-ai/ui/button"
import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field"
import { Icon } from "@opencode-ai/ui/icon"
import { For, Show } from "solid-js"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { ServerConnection } from "@/context/server"
import { createEditProjectModel } from "./edit-project"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
const language = useLanguage()
const model = createEditProjectModel(props)
return (
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
<div class="flex flex-col gap-4">
<TextField
autofocus
type="text"
label={language.t("dialog.project.edit.name")}
placeholder={model.folderName()}
value={model.store.name}
onChange={(v) => model.setStore("name", v)}
/>
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label>
<div class="flex gap-3 items-start">
<div
class="relative"
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
>
<div
class="relative size-16 rounded-md transition-colors cursor-pointer"
classList={{
"border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
"border-border-base hover:border-border-strong": !model.store.dragOver,
"overflow-hidden": !!model.store.iconOverride,
}}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<Show
when={getProjectAvatarSource(props.project.id, {
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
})}
fallback={
<div class="size-full flex items-center justify-center">
<Avatar
fallback={model.store.name || model.defaultName()}
{...getAvatarColors(model.store.color)}
class="size-full text-[32px]"
/>
</div>
}
>
{(src) => (
<img
src={src()}
alt={language.t("dialog.project.edit.icon.alt")}
class="size-full object-cover"
/>
)}
</Show>
</div>
<div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": model.store.iconHover && !model.store.iconOverride,
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
}}
>
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
</div>
<div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
}}
>
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
</div>
</div>
<input
id="icon-upload"
ref={(el) => {
model.setIconInput(el)
}}
type="file"
accept="image/*"
class="hidden"
onChange={model.inputChange}
/>
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
</div>
<Show when={!model.store.iconOverride}>
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
<div class="flex gap-1.5">
<For each={AVATAR_COLOR_KEYS}>
{(color) => (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={model.store.color === color}
classList={{
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
model.store.color === color,
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
model.store.color !== color,
}}
onClick={() => {
if (model.store.color === color && !props.project.icon?.url) return
model.setStore("color", model.store.color === color ? undefined : color)
}}
>
<Avatar
fallback={model.store.name || model.defaultName()}
{...getAvatarColors(color)}
class="size-full rounded"
/>
</button>
)}
</For>
</div>
</div>
</Show>
<TextField
multiline
label={language.t("dialog.project.edit.worktree.startup")}
description={language.t("dialog.project.edit.worktree.startup.description")}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
value={model.store.startup}
onChange={(v) => model.setStore("startup", v)}
spellcheck={false}
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
/>
</div>
<div class="flex justify-end gap-2">
<Button type="button" variant="ghost" size="large" onClick={model.close}>
{language.t("common.cancel")}
</Button>
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</Button>
</div>
</form>
</Dialog>
)
}
+1 -1
View File
@@ -69,7 +69,7 @@ export const DialogFork: Component = () => {
const dir = base64Encode(sdk().directory) const dir = base64Encode(sdk().directory)
sdk() sdk()
.api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } }) .currentApi.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
.then((forked) => { .then((forked) => {
dialog.close() dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id }) prompt.set(restored, undefined, { dir, id: forked.id })
@@ -66,21 +66,23 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
let pathArea: HTMLDivElement | undefined let pathArea: HTMLDivElement | undefined
let navigation = 0 let navigation = 0
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource( const [fallbackPath] = createResource(
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined), () => (missingBase() ? true : undefined),
() => async (): Promise<Path | undefined> => {
sdk.api.location if ((await sdk.protocol) === "v1")
return sdk.legacy.path.get().catch(() => undefined)
return sdk.api.location
.get() .get()
.then( .then((location) => ({
(location): Path => ({
state: "", state: "",
config: "", config: "",
worktree: location.project.directory, worktree: location.project.directory,
directory: location.directory, directory: location.directory,
home: "", home: "",
}), }))
) .catch(() => undefined)
.catch(() => undefined), },
{ initialValue: undefined }, { initialValue: undefined },
) )
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
@@ -102,7 +104,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const base = pickerRoot(cleaned) || root() || start() const base = pickerRoot(cleaned) || root() || start()
if (!base) return { query: value, items: directories.slice(0, 5) } if (!base) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.api.file const files = await sdk.currentApi.file
.find({ .find({
location: { directory: base }, location: { directory: base },
query: pickerFileSearchQuery(base, value, home()), query: pickerFileSearchQuery(base, value, home()),
@@ -132,7 +134,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
existing ?? existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.api.file return sdk.currentApi.file
.list({ location: { directory: absolute } }) .list({ location: { directory: absolute } })
.then((result) => .then((result) =>
result.data.map((entry) => ({ result.data.map((entry) => ({
@@ -57,21 +57,23 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const [filter, setFilter] = createSignal("") const [filter, setFilter] = createSignal("")
let list: ListRef | undefined let list: ListRef | undefined
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource( const [fallbackPath] = createResource(
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined), () => (missingBase() ? true : undefined),
() => async (): Promise<Path | undefined> => {
sdk.api.location if ((await sdk.protocol) === "v1")
return sdk.legacy.path.get().catch(() => undefined)
return sdk.api.location
.get() .get()
.then( .then((location) => ({
(location): Path => ({
state: "", state: "",
config: "", config: "",
worktree: location.project.directory, worktree: location.project.directory,
directory: location.directory, directory: location.directory,
home: "", home: "",
}), }))
) .catch(() => undefined)
.catch(() => undefined), },
{ initialValue: undefined }, { initialValue: undefined },
) )
@@ -10,6 +10,7 @@ const statusLabels = {
connected: "mcp.status.connected", connected: "mcp.status.connected",
failed: "mcp.status.failed", failed: "mcp.status.failed",
needs_auth: "mcp.status.needs_auth", needs_auth: "mcp.status.needs_auth",
needs_client_registration: "mcp.status.needs_client_registration",
disabled: "mcp.status.disabled", disabled: "mcp.status.disabled",
} as const } as const
@@ -56,7 +57,7 @@ export const DialogSelectMcp: Component = () => {
} }
const error = () => { const error = () => {
const s = mcpStatus() const s = mcpStatus()
if (s?.status === "failed") return s.error if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
} }
const enabled = () => status() === "connected" const enabled = () => status() === "connected"
return ( return (
@@ -6,17 +6,22 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { Show } from "solid-js" import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { detectServerProtocol } from "@/utils/server-protocol"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { import { useTabs } from "@/context/tabs"
type ServerDomainController,
type ServerFormController, const DEFAULT_USERNAME = "opencode"
useServerDomainController,
useServerFormController,
} from "@/components/server/server-management-controller"
interface ServerFormProps { interface ServerFormProps {
value: string value: string
@@ -35,6 +40,76 @@ interface ServerFormProps {
onBack: () => void onBack: () => void
} }
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultUrlActions] = createResource(
async () => {
try {
const key = await platform.getDefaultServer?.()
if (!key) return null
return key
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
const setDefault = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultUrlActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
}
function useServerPreview() {
const checkServerHealth = useCheckServerHealth()
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}
const previewStatus = async (
value: string,
username: string,
password: string,
setStatus: (value: boolean | undefined) => void,
) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http)
setStatus(result.healthy)
}
return { previewStatus }
}
function ServerForm(props: ServerFormProps) { function ServerForm(props: ServerFormProps) {
const language = useLanguage() const language = useLanguage()
const keyDown = (event: KeyboardEvent) => { const keyDown = (event: KeyboardEvent) => {
@@ -102,40 +177,401 @@ function ServerForm(props: ServerFormProps) {
export function DialogSelectServer() { export function DialogSelectServer() {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const controller = useServerManagementController({ onSelect: dialog.close })
const domain = useServerDomainController({ onSelect: () => dialog.close() })
const form = useServerFormController({ onSelect: () => dialog.close() })
const title = () => {
if (!form.state.open()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={form.reset} aria-label={language.t("common.goBack")} />
<span>
{form.state.adding() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}
</span>
</div>
)
}
return ( return (
<Dialog title={title()}> <Dialog title={controller.formTitle()}>
<div class="flex flex-1 min-h-0 flex-col px-5"> <div class="flex flex-1 min-h-0 flex-col px-5">
<Show <Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
when={form.state.open()} <ServerConnectionForm controller={controller} />
fallback={<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />}
>
<ServerConnectionForm form={form} />
</Show> </Show>
</div> </div>
</Dialog> </Dialog>
) )
} }
export function ServerConnectionList(props: { export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
domain: ServerDomainController const navigate = useNavigate()
onAdd: () => void const server = useServer()
onEdit: (server: ServerConnection.Http) => void const tabs = useTabs()
}) { const global = useGlobal()
const platform = usePlatform()
const language = useLanguage()
const { defaultKey, canDefault, setDefault } = useDefaultServer()
const { previewStatus } = useServerPreview()
const checkServerHealth = useCheckServerHealth()
const [store, setStore] = createStore({
addServer: {
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
showForm: false,
status: undefined as boolean | undefined,
},
editServer: {
id: undefined as string | undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
status: undefined as boolean | undefined,
},
})
const resetAdd = () => {
setStore("addServer", {
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
showForm: false,
status: undefined,
})
}
const resetEdit = () => {
setStore("editServer", {
id: undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
status: undefined,
})
}
const addMutation = useMutation(() => ({
mutationFn: async (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
if (
!settings.general.newLayoutDesigns() &&
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
if (options.navigateOnAdd === false) {
server.add(conn)
options.onSelect?.()
return
}
await select(conn, true)
},
}))
const editMutation = useMutation(() => ({
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
if (input.original.type !== "http") return
const normalized = normalizeServerUrl(input.value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = input.original.displayName
if (
normalized === input.original.http.url &&
name === existingName &&
username === input.original.http.username &&
password === input.original.http.password
) {
resetEdit()
return
}
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (
!settings.general.newLayoutDesigns() &&
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
replaceServer(input.original, conn)
}
resetEdit()
},
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const originalKey = ServerConnection.key(original)
const active = server.key
tabs.removeServer(originalKey)
const newConn = server.add(next)
if (!newConn) return
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive)
server.remove(originalKey)
}
const items = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)]
})
const settings = useSettings()
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
)
const sortedItems = createMemo(() => {
const raw = items()
const list = settings.general.newLayoutDesigns()
? raw
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
if (!list.length) return list
const active = current()
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
options.onSelect?.()
if (persist && conn.type === "http") {
server.add(conn)
navigate("/")
return
}
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
}
const handleAddChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddNameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { name: value, error: "" })
}
const handleAddUsernameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddPasswordChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }),
)
}
const handleEditChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditNameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { name: value, error: "" })
}
const handleEditUsernameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditPasswordChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }),
)
}
const mode = createMemo<"list" | "add" | "edit">(() => {
if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add"
return "list"
})
const editing = createMemo(() => {
if (!store.editServer.id) return
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
})
const resetForm = () => {
resetAdd()
resetEdit()
}
const startAdd = () => {
resetEdit()
setStore("addServer", {
showForm: true,
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
status: undefined,
})
}
const startEdit = (conn: ServerConnection.Http) => {
resetAdd()
setStore("editServer", {
id: conn.http.url,
value: conn.http.url,
name: conn.displayName ?? "",
username: conn.http.username ?? "",
password: conn.http.password ?? "",
error: "",
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
})
}
const submitForm = () => {
if (mode() === "add") {
if (addMutation.isPending) return
setStore("addServer", { error: "" })
addMutation.mutate(store.addServer.url)
return
}
const original = editing()
if (!original) return
if (editMutation.isPending) return
setStore("editServer", { error: "" })
editMutation.mutate({ original, value: store.editServer.value })
}
const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add")
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
</div>
)
})
createEffect(() => {
if (!store.editServer.id) return
if (editing()) return
resetEdit()
})
async function handleRemove(key: ServerConnection.Key) {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) {
await setDefault(null)
}
} catch (err) {
showRequestError(language, err)
}
}
return {
defaultKey,
canDefault,
current,
sortedItems,
status: () => global.servers.health,
isFormMode,
isAddMode,
formTitle,
formBusy,
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
select,
setDefault,
startAdd,
startEdit,
resetForm,
submitForm,
canRemove: server.canRemove,
handleRemove,
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
}
}
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
@@ -149,10 +585,10 @@ export function ServerConnectionList(props: {
}} }}
noInitialSelection noInitialSelection
emptyMessage={language.t("dialog.server.empty")} emptyMessage={language.t("dialog.server.empty")}
items={props.domain.collection.items} items={props.controller.sortedItems}
key={(x) => x.http.url} key={(x) => x.http.url}
onSelect={(x) => { onSelect={(x) => {
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x) if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x)
}} }}
divider={true} divider={true}
> >
@@ -161,15 +597,15 @@ export function ServerConnectionList(props: {
return ( return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item"> <div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-center w-5"> <div class="flex flex-col h-full items-center w-5">
<ServerHealthIndicator health={props.domain.collection.health()[key]} /> <ServerHealthIndicator health={props.controller.status()[key]} />
</div> </div>
<ServerRow <ServerRow
conn={i} conn={i}
dimmed={props.domain.collection.health()[key]?.healthy === false} dimmed={props.controller.status()[key]?.healthy === false}
status={props.domain.collection.health()[key]} status={props.controller.status()[key]}
class="flex items-center gap-3 min-w-0 flex-1" class="flex items-center gap-3 min-w-0 flex-1"
badge={ badge={
<Show when={props.domain.defaults.key() === ServerConnection.key(i)}> <Show when={props.controller.defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs"> <span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")} {language.t("dialog.server.status.default")}
</span> </span>
@@ -178,12 +614,7 @@ export function ServerConnectionList(props: {
showCredentials showCredentials
/> />
<div class="flex items-center justify-center gap-4 pl-4"> <div class="flex items-center justify-center gap-4 pl-4">
<Show <Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
when={
props.domain.collection.current() &&
ServerConnection.key(props.domain.collection.current()!) === key
}
>
<Icon name="check" class="h-6" /> <Icon name="check" class="h-6" />
</Show> </Show>
@@ -202,27 +633,27 @@ export function ServerConnectionList(props: {
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
if (i.type !== "http") return if (i.type !== "http") return
props.onEdit(i) props.controller.startEdit(i)
}} }}
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}> <Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}> <DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}> <Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}> <DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")} {language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={props.domain.connection.canRemove(key)}> <Show when={props.controller.canRemove(key)}>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => props.domain.connection.remove(key)} onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak" class="text-text-on-critical-base hover:bg-surface-critical-weak"
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
@@ -243,7 +674,7 @@ export function ServerConnectionList(props: {
variant="secondary" variant="secondary"
icon="plus-small" icon="plus-small"
size="large" size="large"
onClick={props.onAdd} onClick={props.controller.startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5" class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
> >
{language.t("dialog.server.add.button")} {language.t("dialog.server.add.button")}
@@ -253,38 +684,38 @@ export function ServerConnectionList(props: {
) )
} }
export function ServerConnectionForm(props: { form: ServerFormController }) { export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
const language = useLanguage() const language = useLanguage()
return ( return (
<div class="flex flex-1 min-h-0 flex-col gap-4"> <div class="flex flex-1 min-h-0 flex-col gap-4">
<ServerForm <ServerForm
value={props.form.state.value()} value={props.controller.formValue()}
name={props.form.state.name()} name={props.controller.formName()}
username={props.form.state.username()} username={props.controller.formUsername()}
password={props.form.state.password()} password={props.controller.formPassword()}
placeholder={language.t("dialog.server.add.placeholder")} placeholder={language.t("dialog.server.add.placeholder")}
busy={props.form.state.busy()} busy={props.controller.formBusy()}
error={props.form.state.error()} error={props.controller.formError()}
status={props.form.state.status()} status={props.controller.formStatus()}
onChange={props.form.change.value} onChange={props.controller.handleFormChange()}
onNameChange={props.form.change.name} onNameChange={props.controller.handleFormNameChange()}
onUsernameChange={props.form.change.username} onUsernameChange={props.controller.handleFormUsernameChange()}
onPasswordChange={props.form.change.password} onPasswordChange={props.controller.handleFormPasswordChange()}
onSubmit={props.form.submit} onSubmit={props.controller.submitForm}
onBack={props.form.reset} onBack={props.controller.resetForm}
/> />
<div class="shrink-0 pb-5"> <div class="shrink-0 pb-5">
<Button <Button
variant="primary" variant="primary"
size="large" size="large"
onClick={props.form.submit} onClick={props.controller.submitForm}
disabled={props.form.state.busy()} disabled={props.controller.formBusy()}
class="px-3 py-1.5" class="px-3 py-1.5"
> >
{props.form.state.busy() {props.controller.formBusy()
? language.t("dialog.server.add.checking") ? language.t("dialog.server.add.checking")
: props.form.state.adding() : props.controller.isAddMode()
? language.t("dialog.server.add.button") ? language.t("dialog.server.add.button")
: language.t("common.save")} : language.t("common.save")}
</Button> </Button>
@@ -0,0 +1,94 @@
import { Component, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
import { SettingsServers } from "./settings-servers"
export const DialogSettings: Component<{ defaultValue?: string }> = (props) => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const showProviders = () => {
void dialog.show(() => <DialogSettings defaultValue="providers" />)
}
return (
<Dialog size="x-large" transition>
<Tabs
orientation="vertical"
variant="settings"
value={tab()}
onChange={(value) => void startTransition(() => setTab(value))}
class="h-full settings-dialog"
>
<Tabs.List>
<div class="flex flex-col justify-between h-full w-full gap-4">
<div class="flex flex-col gap-3 w-full pt-3">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<Tabs.SectionTitle>{language.t("settings.section.desktop")}</Tabs.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<Tabs.Trigger value="general">
<Icon name="sliders" />
{language.t("settings.tab.general")}
</Tabs.Trigger>
<Tabs.Trigger value="shortcuts">
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</Tabs.Trigger>
<Tabs.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Tabs.SectionTitle>{language.t("settings.section.server")}</Tabs.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<Tabs.Trigger value="providers">
<Icon name="providers" />
{language.t("settings.providers.title")}
</Tabs.Trigger>
<Tabs.Trigger value="models">
<Icon name="models" />
{language.t("settings.models.title")}
</Tabs.Trigger>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
<span>{language.t("app.name.desktop")}</span>
<span class="text-11-regular">v{platform.version}</span>
</div>
</div>
</Tabs.List>
<Tabs.Content value="general" class="no-scrollbar">
<SettingsGeneral />
</Tabs.Content>
<Tabs.Content value="shortcuts" class="no-scrollbar">
<SettingsKeybinds />
</Tabs.Content>
<Tabs.Content value="servers" class="no-scrollbar">
<SettingsServers />
</Tabs.Content>
<Tabs.Content value="providers" class="no-scrollbar">
<SettingsProviders onBack={showProviders} />
</Tabs.Content>
<Tabs.Content value="models" class="no-scrollbar">
<SettingsModels />
</Tabs.Content>
</Tabs>
</Dialog>
)
}
@@ -133,7 +133,7 @@ test("scopes file autocomplete to the current browser root", () => {
test("resolves directory autocomplete from the current browser root", async () => { test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = [] const directories: string[] = []
const sdk = { const sdk = {
api: { currentApi: {
file: { file: {
find: (input: { location?: { directory?: string } }) => { find: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "") directories.push(input.location?.directory ?? "")
@@ -155,7 +155,7 @@ test("resolves directory autocomplete from the current browser root", async () =
test("searches from an absolute root without a default base", async () => { test("searches from an absolute root without a default base", async () => {
const directories: string[] = [] const directories: string[] = []
const sdk = { const sdk = {
api: { currentApi: {
file: { file: {
list: (input: { location?: { directory?: string } }) => { list: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "") directories.push(input.location?.directory ?? "")
@@ -342,7 +342,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const key = trimPickerPath(directory) const key = trimPickerPath(directory)
const existing = cache.get(key) const existing = cache.get(key)
if (existing) return existing if (existing) return existing
const request = args.sdk.api.file const request = args.sdk.currentApi.file
.list({ location: { directory: key } }) .list({ location: { directory: key } })
.then((result) => result.data) .then((result) => result.data)
.catch(() => []) .catch(() => [])
@@ -374,7 +374,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path) const query = normalizePickerDrive(input.path)
if (!pathInput) { if (!pathInput) {
const results = await args.sdk.api.file const results = await args.sdk.currentApi.file
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 }) .find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.then((result) => result.data.map((entry) => entry.path)) .then((result) => result.data.map((entry) => entry.path))
.catch(() => []) .catch(() => [])
+18 -6
View File
@@ -9,7 +9,6 @@ import { type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) { export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const supported = !props.project.id || props.project.id === "global"
const dialog = useDialog() const dialog = useDialog()
const global = useGlobal() const global = useGlobal()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server)) const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
@@ -72,9 +71,23 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim() const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") { if (props.project.id && props.project.id !== "global") {
// TODO: Restore project edits when the V2 client exposes a project update API. if ((await serverCtx().sdk.protocol) !== "v1") return
// await serverCtx().sdk.api.project.update({ projectID: props.project.id, name, icon, commands }) const project = await serverCtx()
throw new Error(`Project ${props.project.id} cannot be updated`) .sdk.legacy.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
.then((result) => result.data)
if (!project) return
serverCtx().sync.set("project", (items) =>
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
)
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
} }
serverCtx().sync.project.meta(props.project.worktree, { serverCtx().sync.project.meta(props.project.worktree, {
@@ -88,7 +101,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
function submit(event: SubmitEvent) { function submit(event: SubmitEvent) {
event.preventDefault() event.preventDefault()
if (!supported || save.isPending) return if (save.isPending) return
save.mutate() save.mutate()
} }
@@ -98,7 +111,6 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
folderName, folderName,
defaultName, defaultName,
save, save,
supported,
submit, submit,
drop, drop,
dragOver, dragOver,
+148
View File
@@ -0,0 +1,148 @@
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { createSignal, Show } from "solid-js"
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.png"
// TODO: wire to changelog / seen-state when available
const showPopover = () => true
// can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() {
const settings = useSettings()
const platform = usePlatform()
const [drawerOpen, setDrawerOpen] = createSignal(false)
const windows = () => platform.platform === "desktop" && platform.os === "windows"
return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
<Show when={settings.general.shouldDisplayTabsToast()}>
<div
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
>
<button
type="button"
aria-label="Dismiss Tabs information"
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
onClick={settings.general.dismissTabsToast}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="white" />
</svg>
</button>
<button
type="button"
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
onClick={() => {
settings.general.dismissTabsToast()
setDrawerOpen(true)
}}
>
<video
src={introducingTabsVideo}
class="absolute inset-0 h-full w-full object-cover"
loop
muted
autoplay
playsinline
aria-hidden="true"
onContextMenu={(event) => event.preventDefault()}
/>
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
Introducing Tabs
</p>
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
Organize your work and active sessions with tabs
</p>
</div>
</button>
</div>
</Show>
<DrawerContent
style={
windows()
? {
inset: "0 0 0 auto",
"max-height": "100vh",
"max-width": "100vw",
"border-radius": "0",
}
: undefined
}
>
<Show when={windows()}>
<DrawerClose
as={IconButtonV2}
type="button"
size="small"
variant="neutral"
aria-label="Close"
icon={<IconV2 name="xmark-small" />}
class="absolute top-[10px] left-[-36px]"
/>
</Show>
<div
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
classList={{
"h-[40px] px-4": windows(),
"h-[52px] p-4": !windows(),
}}
>
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
July 14
</p>
<Show when={!windows()}>
<DrawerClose
as={IconButtonV2}
type="button"
size="small"
variant="ghost-muted"
aria-label="Close"
icon={<IconV2 name="xmark-small" />}
/>
</Show>
</div>
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
Introducing Tabs
</p>
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
<p>OpenCode Desktop is now built around tabs.</p>
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
you're starting something new, and close it when you're done.
</p>
<p>
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
memorable if you plan to keep them around.
</p>
<p>
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
</p>
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>When you reopen the app, your tabs are still open.</p>
<p>
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
will become permanent in a few weeks.
</p>
</div>
</div>
</DrawerContent>
</Drawer>
)
}
@@ -5,7 +5,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { ReferenceInfo } from "@opencode-ai/client/promise" import type { ReferenceInfo } from "@/types"
import { createEffect, createMemo, on, Show } from "solid-js" import { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
+1 -1
View File
@@ -81,7 +81,7 @@ import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/place
import { createPromptInputTransientState } from "./prompt-input/transient-state" 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 type { ReferenceInfo } from "@opencode-ai/client/promise" import type { ReferenceInfo } from "@/types"
export { createPromptInputHistory } export { createPromptInputHistory }
export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission } export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
@@ -1,15 +1,12 @@
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types" import type { AgentPartInput, FilePartInput, Part, TextPartInput } from "@/types"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path" import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note" import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
type PromptRequestPart = type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
| (Omit<TextPart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<FilePart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<MessageAgentPart, "id" | "sessionID" | "messageID"> & { id: string })
type ContextFile = { type ContextFile = {
key: string key: string
@@ -31,12 +31,6 @@ const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string
const sentPrompts: string[] = [] const sentPrompts: string[] = []
const promptInputs: unknown[] = [] const promptInputs: unknown[] = []
const sentCommands: unknown[] = [] const sentCommands: unknown[] = []
const switchedAgents: Array<{ sessionID: string; agent: string }> = []
const switchedModels: Array<{
sessionID: string
model: { id: string; providerID: string; variant?: string }
}> = []
const sessionRequestOrder: string[] = []
const commands: Array<{ name: string }> = [] const commands: Array<{ name: string }> = []
let serverSessionSyncs = 0 let serverSessionSyncs = 0
@@ -99,22 +93,10 @@ const clientFor = (directory: string) => {
} }
}, },
prompt: async (input: unknown) => { prompt: async (input: unknown) => {
sessionRequestOrder.push("prompt")
sentPrompts.push(directory) sentPrompts.push(directory)
promptInputs.push(input) promptInputs.push(input)
return { data: undefined } return { data: undefined }
}, },
switchAgent: async (input: { sessionID: string; agent: string }) => {
sessionRequestOrder.push("agent")
switchedAgents.push(input)
},
switchModel: async (input: {
sessionID: string
model: { id: string; providerID: string; variant?: string }
}) => {
sessionRequestOrder.push("model")
switchedModels.push(input)
},
command: async (input: unknown) => { command: async (input: unknown) => {
sentCommands.push(input) sentCommands.push(input)
}, },
@@ -143,6 +125,13 @@ beforeAll(async () => {
useSearchParams: () => [search, () => undefined], useSearchParams: () => [search, () => undefined],
})) }))
mock.module("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: (input: { directory: string }) => {
createdClients.push(input.directory)
return clientFor(input.directory)
},
}))
mock.module("@opencode-ai/ui/toast", () => ({ mock.module("@opencode-ai/ui/toast", () => ({
Toast: { Region: () => null }, Toast: { Region: () => null },
showToast: () => 0, showToast: () => 0,
@@ -208,8 +197,13 @@ beforeAll(async () => {
const sdk = { const sdk = {
scope: "local", scope: "local",
directory: "/repo/main", directory: "/repo/main",
client: rootClient,
api: rootClient.api, api: rootClient.api,
currentApi: rootClient.api,
url: "http://localhost:4096", url: "http://localhost:4096",
createClient(opts: any) {
return clientFor(opts.directory)
},
} }
return () => sdk return () => sdk
}, },
@@ -297,9 +291,6 @@ beforeEach(() => {
sentPrompts.length = 0 sentPrompts.length = 0
promptInputs.length = 0 promptInputs.length = 0
sentCommands.length = 0 sentCommands.length = 0
switchedAgents.length = 0
switchedModels.length = 0
sessionRequestOrder.length = 0
commands.length = 0 commands.length = 0
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
params = {} params = {}
@@ -457,17 +448,13 @@ describe("prompt submit worktree selection", () => {
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }]) expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
}) })
test("switches the selected agent and model before prompting", async () => { test("includes the selected variant on optimistic prompts", async () => {
params = { id: "session-1" } params = { id: "session-1" }
variant = "high" variant = "high"
const submit = createPromptSubmit({ const submit = createPromptSubmit({
prompt, prompt,
info: () => ({ info: () => ({ id: "session-1" }),
id: "session-1",
agent: "old-agent",
model: { id: "old-model", providerID: "old-provider" },
}),
imageAttachments: () => [], imageAttachments: () => [],
commentCount: () => 0, commentCount: () => 0,
autoAccept: () => false, autoAccept: () => false,
@@ -496,14 +483,6 @@ describe("prompt submit worktree selection", () => {
}, },
}) })
expect(sentPrompts).toEqual(["/repo/main"]) expect(sentPrompts).toEqual(["/repo/main"])
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
expect(switchedModels).toEqual([
{
sessionID: "session-1",
model: { id: "model", providerID: "provider", variant: "high" },
},
])
expect(sessionRequestOrder).toEqual(["agent", "model", "prompt"])
expect(promptInputs[0]).toMatchObject({ expect(promptInputs[0]).toMatchObject({
sessionID: "session-1", sessionID: "session-1",
text: "ls", text: "ls",
@@ -1,5 +1,4 @@
import type { Message } from "@/types" import type { Message, Session } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
@@ -16,13 +15,14 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync" import { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
import { getDirectory } from "@opencode-ai/core/util/path"
import { buildRequestParts } from "./build-request-parts" 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" import { createPromptSubmissionState } from "./submission-state"
import { normalizeSessionInfo } from "@/utils/session"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import { getDirectory } from "@opencode-ai/core/util/path"
type PendingPrompt = { type PendingPrompt = {
abort: AbortController abort: AbortController
@@ -42,10 +42,9 @@ export type FollowupDraft = {
} }
type FollowupSendInput = { type FollowupSendInput = {
api: DirectorySDK["api"]["session"] api: DirectorySDK["currentApi"]["session"]
serverSync: ServerSync serverSync: ServerSync
sync: DirectorySync sync: DirectorySync
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
draft: FollowupDraft draft: FollowupDraft
messageID?: string messageID?: string
optimisticBusy?: boolean optimisticBusy?: boolean
@@ -158,25 +157,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false return false
} }
const session = input.session()
if (session?.agent !== input.draft.agent) {
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
}
if (
session?.model?.providerID !== input.draft.model.providerID ||
session.model.id !== input.draft.model.modelID ||
(session.model.variant ?? "default") !== (input.draft.variant ?? "default")
) {
await input.api.switchModel({
sessionID: input.draft.sessionID,
model: {
id: input.draft.model.modelID,
providerID: input.draft.model.providerID,
variant: input.draft.variant,
},
})
}
await input.api.prompt({ await input.api.prompt({
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
id: messageID, id: messageID,
@@ -217,9 +197,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
type PromptSubmitInput = { type PromptSubmitInput = {
prompt: ReturnType<typeof usePrompt> prompt: ReturnType<typeof usePrompt>
info: Accessor< info: Accessor<{ id: string } | undefined>
{ id: string; agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined
>
imageAttachments: Accessor<ImageAttachmentPart[]> imageAttachments: Accessor<ImageAttachmentPart[]>
commentCount: Accessor<number> commentCount: Accessor<number>
autoAccept: Accessor<boolean> autoAccept: Accessor<boolean>
@@ -283,7 +261,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return Promise.resolve() return Promise.resolve()
} }
return sdk() return sdk()
.api.session.interrupt({ sessionID }) .currentApi.session.interrupt({ sessionID })
.catch(() => {}) .catch(() => {})
} }
@@ -310,10 +288,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
} }
const seed = (dir: string, info: SessionInfo) => { const seed = (dir: string, info: Session) => {
serverSync().session.remember(info) serverSync().session.remember(info)
const [, setStore] = serverSync().child(dir) const [, setStore] = serverSync().child(dir)
setStore("session", (list: SessionInfo[]) => { setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id) const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list] const next = [...list]
if (result.found) { if (result.found) {
@@ -367,10 +345,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const worktreeSelection = input.newSessionWorktree?.() || "main" const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory let sessionDirectory = projectDirectory
if (isNewSession) { if (isNewSession) {
if (worktreeSelection === "create") { if (worktreeSelection === "create") {
const createdWorktree = await sdk() const createdWorktree = await sdk()
.api.projectCopy.create({ .currentApi.projectCopy.create({
projectID: sync().data.project, projectID: sync().data.project,
strategy: "git_worktree", strategy: "git_worktree",
directory: getDirectory(projectDirectory), directory: getDirectory(projectDirectory),
@@ -383,6 +362,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}) })
return undefined return undefined
}) })
if (!createdWorktree) return if (!createdWorktree) return
WorktreeState.pending(sdk().scope, createdWorktree.directory) WorktreeState.pending(sdk().scope, createdWorktree.directory)
sessionDirectory = createdWorktree.directory sessionDirectory = createdWorktree.directory
@@ -402,11 +382,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let session = input.info() let session = input.info()
if (!session && isNewSession) { if (!session && isNewSession) {
const created = await sdk() const created = await sdk()
.api.session.create({ .currentApi.session.create({
agent: currentAgent.name, agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
location: { directory: sessionDirectory }, location: { directory: sessionDirectory },
}) })
.then(normalizeSessionInfo)
.catch((err) => { .catch((err) => {
showToast({ showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"), title: language.t("prompt.toast.sessionCreateFailed.title"),
@@ -492,7 +473,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
clearInput() clearInput()
const eventID = Event.ID.create() const eventID = Event.ID.create()
sdk() sdk()
.api.session.shell({ .currentApi.session.shell({
sessionID: session.id, sessionID: session.id,
id: eventID, id: eventID,
command: text, command: text,
@@ -516,7 +497,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const messageID = Identifier.ascending("message") const messageID = Identifier.ascending("message")
serverSync().session.set("session_status", session.id, { type: "busy" }) serverSync().session.set("session_status", session.id, { type: "busy" })
sdk() sdk()
.api.session.command({ .currentApi.session.command({
sessionID: session.id, sessionID: session.id,
id: messageID, id: messageID,
command: commandName, command: commandName,
@@ -613,10 +594,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
void sendFollowupDraft({ void sendFollowupDraft({
api: sdk().api.session, api: sdk().currentApi.session,
sync: sync(), sync: sync(),
serverSync: serverSync(), serverSync: serverSync(),
session: () => input.info() ?? session,
draft, draft,
messageID, messageID,
optimisticBusy: sessionDirectory === projectDirectory, optimisticBusy: sessionDirectory === projectDirectory,
@@ -1,323 +0,0 @@
import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { showToast } from "@/utils/toast"
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
const DEFAULT_USERNAME = "opencode"
type FormMode = "list" | "add" | "edit"
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultKeyActions] = createResource(
async () => {
try {
return (await platform.getDefaultServer?.()) ?? null
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const set = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultKeyActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return {
key: () => defaultKey.latest,
available: createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer),
set,
}
}
function useServerMutations() {
const server = useServer()
const tabs = useTabs()
return {
add: (connection: ServerConnection.Http) => server.add(connection),
replace: (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
replaceServerConnection(originalKey, next, {
active: () => server.key,
removeTabs: (key) => tabs.removeServer(key),
add: (connection) => server.add(connection),
setActive: (key) => server.setActive(key),
remove: (key) => server.remove(key),
}),
}
}
export function useServerActionsController() {
const server = useServer()
const tabs = useTabs()
const platform = usePlatform()
const language = useLanguage()
const defaults = useDefaultServer()
const remove = async (key: ServerConnection.Key) => {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
} catch (err) {
showRequestError(language, err)
}
}
return { defaults, connection: { canRemove: server.canRemove, remove } }
}
export type ServerActionsController = ReturnType<typeof useServerActionsController>
export function useServerCollectionController() {
const server = useServer()
const global = useGlobal()
const settings = useSettings()
const actions = useServerActionsController()
const items = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((item) => item !== current)]
})
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
)
const sorted = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const active = current()
const order = new Map(list.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
return {
collection: {
items: sorted,
current,
health: () => global.servers.health,
},
...actions,
}
}
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
const navigate = useNavigate()
const server = useServer()
const global = useGlobal()
const collection = useServerCollectionController()
const select = async (connection: ServerConnection.Any) => {
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
options.onSelect?.()
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
}
return { ...collection, selection: { select } }
}
export type ServerDomainController = ReturnType<typeof useServerDomainController>
export function useServerFormController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
const navigate = useNavigate()
const server = useServer()
const global = useGlobal()
const language = useLanguage()
const mutations = useServerMutations()
const checkServerHealth = useCheckServerHealth()
const healthPreview = createServerHealthPreview(checkServerHealth)
const [store, setStore] = createStore({
mode: "list" as FormMode,
originalUrl: undefined as string | undefined,
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
error: "",
status: undefined as boolean | undefined,
})
onCleanup(healthPreview.cancel)
const reset = () => {
healthPreview.cancel()
setStore({
mode: "list",
originalUrl: undefined,
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
error: "",
status: undefined,
})
}
const allServers = () => {
if (!server.current || server.list.includes(server.current)) return server.list
return [server.current, ...server.list]
}
const editing = createMemo(() =>
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
)
const request = useMutation(() => ({
mutationFn: async () => {
const normalized = normalizeServerUrl(store.values.url)
if (!normalized) {
reset()
return
}
const original = store.mode === "edit" ? editing() : undefined
if (store.mode === "edit" && !original) return
const name = store.values.name.trim() || undefined
const username = store.values.username || undefined
const password = store.values.password || undefined
if (
original?.type === "http" &&
normalized === original.http.url &&
name === original.displayName &&
username === original.http.username &&
password === original.http.password
) {
reset()
return
}
const connection: ServerConnection.Http = {
type: "http",
displayName: name,
http: {
url: normalized,
username: store.mode === "add" && !password ? undefined : username,
password,
},
}
const result = await checkServerHealth(connection.http)
if (!result.healthy) {
setStore("error", language.t("dialog.server.add.error"))
return
}
if (original?.type === "http") {
if (normalized === original.http.url) mutations.add(connection)
if (normalized !== original.http.url) mutations.replace(ServerConnection.key(original), connection)
reset()
return
}
reset()
if (options.navigateOnAdd === false) {
mutations.add(connection)
options.onSelect?.()
return
}
mutations.add(connection)
options.onSelect?.()
navigate("/")
},
}))
const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status))
const change = (field: keyof ServerFormValues, value: string) => {
if (request.isPending) return
setStore("values", field, value)
setStore("error", "")
if (field !== "name") preview()
}
const startAdd = () => {
reset()
setStore("mode", "add")
}
const startEdit = (connection: ServerConnection.Http) => {
reset()
setStore({
mode: "edit",
originalUrl: connection.http.url,
values: {
url: connection.http.url,
name: connection.displayName ?? "",
username: connection.http.username ?? "",
password: connection.http.password ?? "",
},
error: "",
status: global.servers.health[ServerConnection.key(connection)]?.healthy,
})
}
const submit = () => {
if (store.mode === "list" || request.isPending) return
setStore("error", "")
request.mutate()
}
createEffect(() => {
if (store.mode !== "edit") return
if (editing()) return
reset()
})
return {
state: {
mode: () => store.mode,
open: () => store.mode !== "list",
adding: () => store.mode === "add",
busy: () => request.isPending,
value: () => store.values.url,
name: () => store.values.name,
username: () => store.values.username,
password: () => store.values.password,
error: () => store.error,
status: () => store.status,
},
change: {
value: (value: string) => change("url", value),
name: (value: string) => change("name", value),
username: (value: string) => change("username", value),
password: (value: string) => change("password", value),
},
start: { add: startAdd, edit: startEdit },
reset,
submit,
}
}
export type ServerFormController = ReturnType<typeof useServerFormController>
@@ -1,99 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
const values = (url: string): ServerFormValues => ({ url, name: "", username: "opencode", password: "" })
describe("createServerHealthPreview", () => {
test("ignores an older response that resolves after the latest response", async () => {
const first = deferred<{ healthy: boolean }>()
const second = deferred<{ healthy: boolean }>()
const requests = [first, second]
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => requests.shift()!.promise)
const older = preview.preview(values("old.example.com"), (value) => status.push(value))
const latest = preview.preview(values("new.example.com"), (value) => status.push(value))
second.resolve({ healthy: true })
await latest
first.resolve({ healthy: false })
await older
expect(status).toEqual([undefined, undefined, true])
})
test("an incomplete value invalidates an in-flight response", async () => {
const request = deferred<{ healthy: boolean }>()
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => request.promise)
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
await preview.preview(values("server"), (value) => status.push(value))
request.resolve({ healthy: true })
await pending
expect(status).toEqual([undefined, undefined])
})
test("cancellation prevents an in-flight response from updating status", async () => {
const request = deferred<{ healthy: boolean }>()
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => request.promise)
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
preview.cancel()
request.resolve({ healthy: true })
await pending
expect(status).toEqual([undefined])
})
})
describe("replaceServerConnection", () => {
const original: ServerConnection.Http = { type: "http", http: { url: "https://old.example.com" } }
const next: ServerConnection.Http = { type: "http", http: { url: "https://new.example.com" } }
test("moves active selection after adding the replacement and removes the original", () => {
const calls: string[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: (key) => calls.push(`tabs:${key}`),
add: (server) => {
calls.push(`add:${ServerConnection.key(server)}`)
return server
},
setActive: (key) => calls.push(`active:${key}`),
remove: (key) => calls.push(`remove:${key}`),
})
expect(calls).toEqual([
"tabs:https://old.example.com",
"add:https://new.example.com",
"active:https://new.example.com",
"remove:https://old.example.com",
])
})
test("keeps the original when the replacement cannot be added", () => {
const removed: ServerConnection.Key[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: () => {},
add: () => undefined,
setActive: () => {},
remove: (key) => removed.push(key),
})
expect(removed).toEqual([])
})
})
@@ -1,59 +0,0 @@
import { normalizeServerUrl, ServerConnection } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
export type ServerFormValues = {
url: string
name: string
username: string
password: string
}
export function createServerHealthPreview(
check: (server: ServerConnection.HttpBase) => Promise<Pick<ServerHealth, "healthy">>,
) {
let generation = 0
const cancel = () => {
generation += 1
}
const preview = async (values: ServerFormValues, setStatus: (value: boolean | undefined) => void) => {
const current = ++generation
setStatus(undefined)
const normalized = normalizeServerUrl(values.url)
if (!normalized) return
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return
if (!host.includes("localhost") && !host.startsWith("127.0.0.1") && !host.includes(".") && !host.includes(":"))
return
const http: ServerConnection.HttpBase = { url: normalized }
if (values.username) http.username = values.username
if (values.password) http.password = values.password
const result = await check(http)
if (current !== generation) return
setStatus(result.healthy)
}
return { cancel, preview }
}
export function replaceServerConnection(
originalKey: ServerConnection.Key,
next: ServerConnection.Http,
operations: {
active: () => ServerConnection.Key | undefined
removeTabs: (key: ServerConnection.Key) => void
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
setActive: (key: ServerConnection.Key) => void
remove: (key: ServerConnection.Key) => void
},
) {
const active = operations.active()
operations.removeTabs(originalKey)
const added = operations.add(next)
if (!added) return
const nextActive = active === originalKey ? ServerConnection.key(added) : active
if (nextActive) operations.setActive(nextActive)
operations.remove(originalKey)
}
@@ -2,13 +2,13 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { type Component, Show } from "solid-js" import { type Component, Show } from "solid-js"
import type { ServerActionsController } from "@/components/server/server-management-controller" import { useServerManagementController } from "@/components/dialog-select-server"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
export const ServerRowMenu: Component<{ export const ServerRowMenu: Component<{
server: ServerConnection.Any server: ServerConnection.Any
domain: ServerActionsController controller: ReturnType<typeof useServerManagementController>
onEdit: (server: ServerConnection.Http) => void onEdit: (server: ServerConnection.Http) => void
open?: boolean open?: boolean
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void
@@ -19,13 +19,13 @@ export const ServerRowMenu: Component<{
<ServerRowMenuView <ServerRowMenuView
server={props.server} server={props.server}
labels={serverMenuLabels(language)} labels={serverMenuLabels(language)}
canDefault={props.domain.defaults.available()} canDefault={props.controller.canDefault()}
isDefault={props.domain.defaults.key() === key} isDefault={props.controller.defaultKey() === key}
canRemove={props.domain.connection.canRemove(key)} canRemove={props.controller.canRemove(key)}
onEdit={props.onEdit} onEdit={props.onEdit}
onSetDefault={() => props.domain.defaults.set(key)} onSetDefault={() => props.controller.setDefault(key)}
onRemoveDefault={() => props.domain.defaults.set(null)} onRemoveDefault={() => props.controller.setDefault(null)}
onRemove={() => props.domain.connection.remove(key)} onRemove={() => props.controller.handleRemove(key)}
open={props.open} open={props.open}
onOpenChange={props.onOpenChange} onOpenChange={props.onOpenChange}
/> />
@@ -0,0 +1,792 @@
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "./updater-action"
import {
monoDefault,
monoFontFamily,
monoInput,
sansDefault,
sansFontFamily,
sansInput,
terminalDefault,
terminalFontFamily,
terminalInput,
useSettings,
} from "@/context/settings"
import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
import { SettingsList } from "./settings-list"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined,
run: 0,
}
type ThemeOption = {
id: string
name: string
}
type ShellOption = {
path: string
name: string
acceptable: boolean
}
type ShellSelectOption = {
id: string
value: string
label: string
}
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const stopDemoSound = () => {
demoSoundState.run += 1
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
}
clearTimeout(demoSoundState.timeout)
demoSoundState.cleanup = undefined
}
const playDemoSound = (id: string | undefined) => {
stopDemoSound()
if (!id) return
const run = ++demoSoundState.run
demoSoundState.timeout = setTimeout(() => {
void playSoundById(id).then((cleanup) => {
if (demoSoundState.run !== run) {
cleanup?.()
return
}
demoSoundState.cleanup = cleanup
})
}, 100)
}
export const SettingsGeneral: Component = () => {
const theme = useTheme()
const language = useLanguage()
const permission = usePermission()
const platform = usePlatform()
const dialog = useDialog()
const params = useParams()
const settings = useSettings()
const updater = useUpdaterAction()
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir))
const accepting = createMemo(() => {
const value = dir()
if (!value) return false
if (!params.id) return permission.isAutoAcceptingDirectory(value)
return permission.isAutoAccepting(params.id, value)
})
const toggleAccept = (checked: boolean) => {
const value = dir()
if (!value) return
if (!params.id) {
if (permission.isAutoAcceptingDirectory(value) === checked) return
permission.toggleAutoAcceptDirectory(value)
return
}
if (checked) {
permission.enableAutoAccept(params.id, value)
return
}
permission.disableAutoAccept(params.id, value)
}
const desktop = createMemo(() => platform.platform === "desktop")
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const [shells] = createResource(
() => (protocol() === "v1" ? serverSdk() : undefined),
(sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
() => (linux() && platform.getDisplayBackend ? true : false),
() => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
{ initialValue: null as DisplayBackend | null },
)
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
{ initialValue: false },
)
onMount(() => {
void theme.loadThemes()
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = serverSync().data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
}
const options = [
autoOption,
...list.map((s) => {
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
const text = ambiguousName ? s.path : s.name
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
return {
id: s.path,
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
value: ambiguousName ? s.path : s.name,
label,
}
}),
]
if (current && !options.some((o) => o.value === current)) {
options.push({ id: current, value: current, label: current })
}
return options
})
const onDisplayBackendChange = (checked: boolean) => {
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
if (!update) return
void update.finally(() => {
void refetchDisplayBackend()
})
}
const onPinchZoomChange = (checked: boolean) => {
setPinchZoom(checked)
const update = platform.setPinchZoomEnabled?.(checked)
if (!update) return
void update.catch(() => setPinchZoom(!checked))
}
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
{ value: "light", label: language.t("theme.scheme.light") },
{ value: "dark", label: language.t("theme.scheme.dark") },
])
const languageOptions = createMemo(() =>
language.locales.map((locale) => ({
value: locale,
label: language.label(locale),
})),
)
const noneSound = { id: "none", label: "sound.option.none" } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const mono = () => monoInput(settings.appearance.font())
const sans = () => sansInput(settings.appearance.uiFont())
const terminal = () => terminalInput(settings.appearance.terminalFont())
const soundSelectProps = (
enabled: () => boolean,
current: () => string,
setEnabled: (value: boolean) => void,
set: (id: string) => void,
) => ({
options: soundOptions,
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
value: (o: (typeof soundOptions)[number]) => o.id,
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
playDemoSound(option.id === "none" ? undefined : option.id)
},
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
if (option.id === "none") {
setEnabled(false)
stopDemoSound()
return
}
setEnabled(true)
set(option.id)
playDemoSound(option.id)
},
variant: "secondary" as const,
size: "small" as const,
triggerVariant: "settings" as const,
})
const InterfaceSection = () => (
<div class="flex flex-col gap-1">
<SettingsList>
<SettingsRow
title={
<span class="flex items-center gap-2">
{language.t("settings.general.row.newInterface.title")}
<Tag variant="accent">{language.t("settings.general.row.newInterface.badge")}</Tag>
</span>
}
description={language.t("settings.general.row.newInterface.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (!checked) return
void import("@/components/settings-v2").then((module) => {
void dialog.show(() => <module.DialogSettings />)
})
}}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const InterfaceNoticeSection = () => (
<div class="flex flex-col gap-1">
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description")}
>
<Button size="small" variant="ghost" onClick={settings.general.dismissNewInterfaceNotice}>
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
</Button>
</SettingsRow>
</SettingsList>
</div>
)
const GeneralSection = () => (
<div class="flex flex-col gap-1">
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<Select
data-action="settings-language"
options={languageOptions()}
current={languageOptions().find((o) => o.value === language.locale())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && language.setLocale(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
>
<div data-action="settings-auto-accept-permissions">
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
</div>
</SettingsRow>
<Show when={protocol() === "v1"}>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Select
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value })
}}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
>
<div data-action="settings-feed-reasoning-summaries">
<Switch
checked={settings.general.showReasoningSummaries()}
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
>
<div data-action="settings-feed-shell-tool-parts-expanded">
<Switch
checked={settings.general.shellToolPartsExpanded()}
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.editToolPartsExpanded.title")}
description={language.t("settings.general.row.editToolPartsExpanded.description")}
>
<div data-action="settings-feed-edit-tool-parts-expanded">
<Switch
checked={settings.general.editToolPartsExpanded()}
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const AdvancedSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.showFileTree.title")}
description={language.t("settings.general.row.showFileTree.description")}
>
<div data-action="settings-show-file-tree">
<Switch
checked={settings.general.showFileTree()}
onChange={(checked) => settings.general.setShowFileTree(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showNavigation.title")}
description={language.t("settings.general.row.showNavigation.description")}
>
<div data-action="settings-show-navigation">
<Switch
checked={settings.general.showNavigation()}
onChange={(checked) => settings.general.setShowNavigation(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}
>
<div data-action="settings-show-search">
<Switch
checked={settings.general.showSearch()}
onChange={(checked) => settings.general.setShowSearch(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
>
<div data-action="settings-show-status">
<Switch
checked={settings.general.showStatus()}
onChange={(checked) => settings.general.setShowStatus(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showCustomAgents.title")}
description={language.t("settings.general.row.showCustomAgents.description")}
>
<div data-action="settings-show-custom-agents">
<Switch
checked={settings.general.showCustomAgents()}
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const AppearanceSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<Select
data-action="settings-color-scheme"
options={colorSchemeOptions()}
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "220px" }}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
</>
}
>
<Select
data-action="settings-theme"
options={themeOptions()}
current={themeOptions().find((o) => o.id === theme.themeId())}
value={(o) => o.id}
label={(o) => o.name}
onSelect={(option) => {
if (!option) return
theme.setTheme(option.id)
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.uiFont.title")}
description={language.t("settings.general.row.uiFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextField
data-action="settings-ui-font"
label={language.t("settings.general.row.uiFont.title")}
hideLabel
type="text"
value={sans()}
onChange={(value) => settings.appearance.setUIFont(value)}
placeholder={sansDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="text-12-regular"
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.font.title")}
description={language.t("settings.general.row.font.description")}
>
<div class="w-full sm:w-[220px]">
<TextField
data-action="settings-code-font"
label={language.t("settings.general.row.font.title")}
hideLabel
type="text"
value={mono()}
onChange={(value) => settings.appearance.setFont(value)}
placeholder={monoDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="text-12-regular"
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.terminalFont.title")}
description={language.t("settings.general.row.terminalFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextField
data-action="settings-terminal-font"
label={language.t("settings.general.row.terminalFont.title")}
hideLabel
type="text"
value={terminal()}
onChange={(value) => settings.appearance.setTerminalFont(value)}
placeholder={terminalDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="text-12-regular"
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const NotificationsSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
>
<div data-action="settings-notifications-agent">
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.permissions.title")}
description={language.t("settings.general.notifications.permissions.description")}
>
<div data-action="settings-notifications-permissions">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.errors.title")}
description={language.t("settings.general.notifications.errors.description")}
>
<div data-action="settings-notifications-errors">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const SoundsSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<Select
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agentEnabled(),
() => settings.sounds.agent(),
(value) => settings.sounds.setAgentEnabled(value),
(id) => settings.sounds.setAgent(id),
)}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<Select
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissionsEnabled(),
() => settings.sounds.permissions(),
(value) => settings.sounds.setPermissionsEnabled(value),
(id) => settings.sounds.setPermissions(id),
)}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<Select
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errorsEnabled(),
() => settings.sounds.errors(),
(value) => settings.sounds.setErrorsEnabled(value),
(id) => settings.sounds.setErrors(id),
)}
/>
</SettingsRow>
</SettingsList>
</div>
)
const UpdatesSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
>
<div data-action="settings-release-notes">
<Switch
checked={settings.general.releaseNotes()}
onChange={(checked) => settings.general.setReleaseNotes(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}>
{language.t(updater.action().label)}
</Button>
</SettingsRow>
</SettingsList>
</div>
)
const DisplaySection = () => (
<Show when={desktop()}>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.pinchZoom.title")}
description={language.t("settings.general.row.pinchZoom.description")}
>
<div data-action="settings-pinch-zoom">
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRow>
<Show when={linux()}>
<SettingsRow
title={
<div class="flex items-center gap-2">
<span>{language.t("settings.general.row.wayland.title")}</span>
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
<span class="text-text-weak">
<Icon name="help" size="small" />
</span>
</Tooltip>
</div>
}
description={language.t("settings.general.row.wayland.description")}
>
<div data-action="settings-wayland">
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
</div>
</SettingsRow>
</Show>
</SettingsList>
</div>
</Show>
)
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8">
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
</div>
</div>
<div class="flex flex-col gap-8 w-full">
<Show when={settings.general.layoutTransitionAvailable()}>
<InterfaceSection />
</Show>
<Show when={settings.general.newInterfaceNoticeVisible()}>
<InterfaceNoticeSection />
</Show>
<GeneralSection />
<AppearanceSection />
<NotificationsSection />
<SoundsSection />
<UpdatesSection />
<DisplaySection />
<Show when={desktop()}>
<AdvancedSection />
</Show>
</div>
</div>
)
}
interface SettingsRowProps {
title: string | JSX.Element
description: string | JSX.Element
children: JSX.Element
}
const SettingsRow: Component<SettingsRowProps> = (props) => {
return (
<div class="flex flex-wrap items-center gap-4 py-3 border-b border-border-weak-base last:border-none sm:flex-nowrap">
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="text-14-medium text-text-strong">{props.title}</span>
<span class="text-12-regular text-text-weak">{props.description}</span>
</div>
<div class="flex w-full justify-end sm:w-auto sm:shrink-0">{props.children}</div>
</div>
)
}
@@ -0,0 +1,149 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers"
import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
const ListLoadingState: Component<{ label: string }> = (props) => {
return (
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{props.label}</span>
</div>
)
}
const ListEmptyState: Component<{ message: string; filter: string }> = (props) => {
return (
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{props.message}</span>
<Show when={props.filter}>
<span class="text-14-regular text-text-strong mt-1">&quot;{props.filter}&quot;</span>
</Show>
</div>
)
}
export const SettingsModels: Component = () => {
return (
<SettingsServerScope>
<SettingsModelsContent />
</SettingsServerScope>
)
}
const SettingsModelsContent: Component = () => {
const language = useLanguage()
const models = useModels()
const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(),
key: (x) => `${x.provider.id}:${x.id}`,
filterKeys: ["provider.name", "name", "id"],
sortBy: (a, b) => a.name.localeCompare(b.name),
groupBy: (x) => x.provider.id,
sortGroupsBy: (a, b) => {
const aIndex = popularProviders.indexOf(a.category)
const bIndex = popularProviders.indexOf(b.category)
const aPopular = aIndex >= 0
const bPopular = bIndex >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
if (aPopular && bPopular) return aIndex - bIndex
const aName = a.items[0].provider.name
const bName = b.items[0].provider.name
return aName.localeCompare(bName)
},
})
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
<SettingsServerPicker />
</div>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
value={list.filter()}
onChange={list.onInput}
placeholder={language.t("dialog.model.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="flex-1"
/>
<Show when={list.filter()}>
<IconButton icon="circle-x" variant="ghost" onClick={list.clear} />
</Show>
</div>
</div>
</div>
<div class="flex flex-col gap-8 max-w-[720px]">
<Show
when={!list.grouped.loading}
fallback={
<ListLoadingState label={`${language.t("common.loading")}${language.t("common.loading.ellipsis")}`} />
}
>
<Show
when={list.flat().length > 0}
fallback={<ListEmptyState message={language.t("dialog.model.empty")} filter={list.filter()} />}
>
<For each={list.grouped.latest}>
{(group) => (
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 pb-2">
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
</div>
<SettingsList>
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<div class="min-w-0">
<span class="text-14-regular text-text-strong truncate block">{item.name}</span>
</div>
<div class="flex-shrink-0">
<Switch
checked={models.visible(key)}
onChange={(checked) => {
models.setVisibility(key, checked)
}}
hideLabel
>
{item.name}
</Switch>
</div>
</div>
)
}}
</For>
</SettingsList>
</div>
)}
</For>
</Show>
</Show>
</div>
</div>
)
}
@@ -0,0 +1,266 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tag } from "@opencode-ai/ui/tag"
import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
const PROVIDER_NOTES = [
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
{ match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" },
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
{ match: (id: string) => id === "google", key: "dialog.provider.google.note" },
{ match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
] as const
export const SettingsProviders: Component<{ onBack?: () => void }> = (props) => {
return (
<SettingsServerScope>
<SettingsProvidersContent onBack={props.onBack} />
</SettingsServerScope>
)
}
const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync()
const providers = useProviders(() => undefined)
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => {
providerConnect.select(provider)
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
}
const connected = createMemo(() => {
return providers
.connected()
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
})
const popular = createMemo(() => {
const connectedIDs = new Set(connected().map((p) => p.id))
const items = providers
.popular()
.filter((p) => !connectedIDs.has(p.id))
.slice()
items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id))
return items
})
const source = (item: ProviderItem): ProviderSource | undefined => {
if (!("source" in item)) return
const value = item.source
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
return
}
const type = (item: ProviderItem) => {
const current = source(item)
if (current === "env") return language.t("settings.providers.tag.environment")
if (current === "api") return language.t("provider.connect.method.apiKey")
if (current === "config") {
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.config")
}
if (current === "custom") return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.other")
}
const canDisconnect = (item: ProviderItem) =>
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = serverSync().data.config.provider?.[providerID]
if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false
return true
}
const disableProvider = async (providerID: string, name: string) => {
if (protocol() !== "v1") return
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next)
await serverSync()
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
})
})
.catch((err: unknown) => {
serverSync().set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSDK().legacy.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name)
return
}
await serverSDK()
.currentApi.integration.get({ integrationID: providerID })
.then(async (integration) => {
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all(
credentials.map((credential) => serverSDK().currentApi.credential.remove({ credentialID: credential.id })),
)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
})
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex items-center justify-between gap-4 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
<SettingsServerPicker />
</div>
</div>
<div class="flex flex-col gap-8 max-w-[720px]">
<div class="flex flex-col gap-1" data-component="connected-providers-section">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
<SettingsList>
<Show
when={connected().length > 0}
fallback={
<div class="py-4 text-14-regular text-text-weak">
{language.t("settings.providers.connected.empty")}
</div>
}
>
<For each={connected()}>
{(item) => (
<div class="group flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
<div class="flex items-center gap-3 min-w-0">
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong truncate">{item.name}</span>
<Tag>{type(item)}</Tag>
</div>
<Show
when={canDisconnect(item)}
fallback={
<span class="text-14-regular text-text-base opacity-0 group-hover:opacity-100 transition-opacity duration-200 pr-3 cursor-default">
{language.t("settings.providers.connected.environmentDescription")}
</span>
}
>
<Button size="large" variant="ghost" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</Button>
</Show>
</div>
)}
</For>
</Show>
</SettingsList>
</div>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.popular")}</h3>
<SettingsList>
<For each={popular()}>
{(item) => (
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-x-3">
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{item.name}</span>
<Show when={item.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={item.id === "opencode-go"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
</div>
<Show when={note(item.id)}>
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
</Show>
</div>
<Button size="large" variant="secondary" icon="plus-small" onClick={() => connect(item.id)}>
{language.t("common.connect")}
</Button>
</div>
)}
</For>
<Show when={protocol() === "v1"}>
<div
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
data-component="custom-provider-section"
>
<div class="flex flex-col min-w-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
<span class="text-12-regular text-text-weak pl-8">
{language.t("settings.providers.custom.description")}
</span>
</div>
<Button
size="large"
variant="secondary"
icon="plus-small"
onClick={() => {
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
</Button>
</div>
</Show>
</SettingsList>
<Button
variant="ghost"
class="px-0 py-0 mt-5 text-14-medium text-text-interactive-base text-left justify-start hover:bg-transparent active:bg-transparent"
onClick={() => connect()}
>
{language.t("dialog.provider.viewAll")}
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,106 @@
import { Button } from "@opencode-ai/ui/button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { QueryClientProvider } from "@tanstack/solid-query"
import { createMemo, For, type ParentProps, Show } from "solid-js"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { ModelsProvider } from "@/context/models"
import { ServerConnection } from "@/context/server"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
export function SettingsServerScope(props: ParentProps) {
const global = useGlobal()
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={props.children}>
<Show when={global.settings.server.selected()}>
{(server) => <SettingsServerDataProviders server={server()}>{props.children}</SettingsServerDataProviders>}
</Show>
</Show>
)
}
function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) {
const global = useGlobal()
const serverCtx = () => global.ensureServerCtx(props.server)
return (
<QueryClientProvider client={serverCtx().queryClient}>
<ServerSDKProvider server={() => props.server}>
<ServerSyncProvider>
<ModelsProvider>{props.children}</ModelsProvider>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryClientProvider>
)
}
export function SettingsServerPicker() {
const global = useGlobal()
const settings = useSettings()
const selected = createMemo(() =>
settings.general.newLayoutDesigns() ? global.settings.server.selected() : undefined,
)
return (
<Show when={selected()}>
{(conn) => (
<DropdownMenu gutter={4} placement="bottom-end">
<DropdownMenu.Trigger
as={Button}
variant="secondary"
size="large"
class="h-8 max-w-[260px] gap-2 px-2 py-1.5 data-[expanded]:bg-surface-base-active"
>
<ServerHealthIndicator health={global.servers.health[ServerConnection.key(conn())]} />
<ServerRow
conn={conn()}
status={global.servers.health[ServerConnection.key(conn())]}
class="flex items-center gap-2 min-w-0 flex-1"
nameClass="text-14-regular text-text-base truncate"
versionClass="hidden"
/>
<Icon name="chevron-down" size="small" class="text-icon-weak shrink-0" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="w-[320px] mt-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-2 [&_[data-slot=dropdown-menu-radio-item]]:pr-2">
<DropdownMenu.RadioGroup
value={global.settings.server.key}
onChange={(key) => {
if (typeof key === "string") global.settings.server.set(ServerConnection.Key.make(key))
}}
>
<For each={global.servers.list()}>
{(item) => {
const key = ServerConnection.key(item)
const blocked = () => global.servers.health[key]?.healthy === false
return (
<DropdownMenu.RadioItem value={key} disabled={blocked()}>
<ServerHealthIndicator health={global.servers.health[key]} />
<ServerRow
conn={item}
dimmed={blocked()}
status={global.servers.health[key]}
class="flex items-center gap-2 min-w-0 flex-1"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
/>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)
}}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)}
</Show>
)
}
@@ -0,0 +1,33 @@
import { Show, type Component } from "solid-js"
import { useLanguage } from "@/context/language"
import { ServerConnectionForm, ServerConnectionList, useServerManagementController } from "./dialog-select-server"
export const SettingsServers: Component = () => {
const language = useLanguage()
const controller = useServerManagementController()
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="flex flex-col flex-1 min-h-0 max-w-[720px]">
<Show
when={controller.isFormMode()}
fallback={
<>
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8">
<h2 class="text-16-medium text-text-strong">{language.t("status.popover.tab.servers")}</h2>
</div>
</div>
<ServerConnectionList controller={controller} />
</>
}
>
<div class="flex flex-1 min-h-0 flex-col gap-4 pt-6">
<div class="text-16-medium text-text-strong">{controller.formTitle()}</div>
<ServerConnectionForm controller={controller} />
</div>
</Show>
</div>
</div>
)
}
@@ -6,7 +6,7 @@ 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"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { type ServerConnection } from "@/context/server" import { type ServerConnection } from "@/context/server"
import { useServerFormController } from "../server/server-management-controller" import { useServerManagementController } from "../dialog-select-server"
import "./settings-v2.css" import "./settings-v2.css"
export const DialogServerV2: Component<{ export const DialogServerV2: Component<{
@@ -15,39 +15,39 @@ export const DialogServerV2: Component<{
}> = (props) => { }> = (props) => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const form = useServerFormController({ const controller = useServerManagementController({
onSelect: () => dialog.close(), onSelect: () => dialog.close(),
navigateOnAdd: false, navigateOnAdd: false,
}) })
const [opened, setOpened] = createSignal(false) const [opened, setOpened] = createSignal(false)
onMount(() => { onMount(() => {
if (props.mode === "add") form.start.add() if (props.mode === "add") controller.startAdd()
if (props.mode === "edit" && props.server) form.start.edit(props.server) if (props.mode === "edit" && props.server) controller.startEdit(props.server)
setOpened(true) setOpened(true)
}) })
onCleanup(() => { onCleanup(() => {
form.reset() controller.resetForm()
}) })
createEffect(() => { createEffect(() => {
if (!opened()) return if (!opened()) return
if (form.state.open()) return if (controller.isFormMode()) return
dialog.close() dialog.close()
}) })
const keyDown = (event: KeyboardEvent) => { const keyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.isComposing) return if (event.key !== "Enter" || event.isComposing) return
event.preventDefault() event.preventDefault()
form.submit() controller.submitForm()
} }
const title = () => const title = () =>
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title") props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
const submitLabel = () => { const submitLabel = () => {
if (form.state.busy()) return language.t("dialog.server.add.checking") if (controller.formBusy()) return language.t("dialog.server.add.checking")
if (props.mode === "add") return language.t("dialog.server.add.button") if (props.mode === "add") return language.t("dialog.server.add.button")
return language.t("common.save") return language.t("common.save")
} }
@@ -66,16 +66,16 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={form.state.value()} value={controller.formValue()}
placeholder={language.t("dialog.server.add.placeholder")} placeholder={language.t("dialog.server.add.placeholder")}
invalid={!!form.state.error()} invalid={!!controller.formError()}
disabled={form.state.busy()} disabled={controller.formBusy()}
autofocus autofocus
onInput={(event) => form.change.value(event.currentTarget.value)} onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
<Show when={form.state.error()}> <Show when={controller.formError()}>
<span class="settings-v2-server-dialog-error">{form.state.error()}</span> <span class="settings-v2-server-dialog-error">{controller.formError()}</span>
</Show> </Show>
</div> </div>
<div class="flex w-full min-w-0 flex-col gap-2"> <div class="flex w-full min-w-0 flex-col gap-2">
@@ -84,10 +84,10 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={form.state.name()} value={controller.formName()}
placeholder={language.t("dialog.server.add.namePlaceholder")} placeholder={language.t("dialog.server.add.namePlaceholder")}
disabled={form.state.busy()} disabled={controller.formBusy()}
onInput={(event) => form.change.name(event.currentTarget.value)} onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -98,10 +98,10 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={form.state.username()} value={controller.formUsername()}
placeholder={language.t("dialog.server.add.usernamePlaceholder")} placeholder={language.t("dialog.server.add.usernamePlaceholder")}
disabled={form.state.busy()} disabled={controller.formBusy()}
onInput={(event) => form.change.username(event.currentTarget.value)} onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -111,10 +111,10 @@ export const DialogServerV2: Component<{
type="password" type="password"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={form.state.password()} value={controller.formPassword()}
placeholder={language.t("dialog.server.add.passwordPlaceholder")} placeholder={language.t("dialog.server.add.passwordPlaceholder")}
disabled={form.state.busy()} disabled={controller.formBusy()}
onInput={(event) => form.change.password(event.currentTarget.value)} onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -122,10 +122,10 @@ export const DialogServerV2: Component<{
</div> </div>
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
<ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}> <ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
{language.t("common.cancel")} {language.t("common.cancel")}
</ButtonV2> </ButtonV2>
<ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}> <ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
{submitLabel()} {submitLabel()}
</ButtonV2> </ButtonV2>
</DialogFooter> </DialogFooter>
@@ -33,7 +33,7 @@ export const DialogSettings: Component<{
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined return draft?.type === "draft" ? draft.directory : undefined
} }
if (route.type === "session") return serverSync().session.get(route.sessionId)?.location.directory if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
return undefined return undefined
}) })
@@ -5,10 +5,12 @@ import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { Switch } from "@opencode-ai/ui/v2/switch-v2"
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 { 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"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action" import { useUpdaterAction } from "../updater-action"
import { import {
monoDefault, monoDefault,
@@ -26,6 +28,7 @@ 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"
import { SettingsRowV2 } from "./parts/row" import { SettingsRowV2 } from "./parts/row"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
import "./settings-v2.css" import "./settings-v2.css"
let demoSoundState = { let demoSoundState = {
@@ -85,15 +88,18 @@ export const SettingsGeneralV2: Component<{
const language = useLanguage() const language = useLanguage()
const permission = usePermission() const permission = usePermission()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog()
const settings = useSettings() const settings = useSettings()
const serverSync = useServerSync() const serverSync = useServerSync()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const mobile = createMediaQuery("(max-width: 767px)") const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction() const updater = useUpdaterAction()
const dir = createMemo(() => { const dir = createMemo(() => {
if (!props.sessionID) return undefined if (!props.sessionID) return undefined
return serverSync().session.lineage.peek(props.sessionID)?.session.location.directory return serverSync().session.lineage.peek(props.sessionID)?.session.directory
}) })
const accepting = createMemo(() => { const accepting = createMemo(() => {
const value = dir() const value = dir()
@@ -117,11 +123,8 @@ export const SettingsGeneralV2: Component<{
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 [shells] = createResource( const [shells] = createResource(
async () => { () => (protocol() === "v1" ? serverSdk() : undefined),
// TODO: Restore executable shell discovery when the V2 client exposes it. (sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] }, { initialValue: [] as ShellOption[] },
) )
@@ -222,6 +225,31 @@ export const SettingsGeneralV2: Component<{
}, },
}) })
const InterfaceSection = () => (
<LayoutTransitionToggle
title={language.t("settings.general.row.newInterface.title")}
badge={language.t("settings.general.row.newInterface.badge")}
description={language.t("settings.general.row.newInterface.description")}
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (checked) return
void import("@/components/dialog-settings").then((module) => {
void dialog.show(() => <module.DialogSettings />)
})
}}
/>
)
const InterfaceNoticeSection = () => (
<LayoutRetirementNotice
title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description")}
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
onDismiss={settings.general.dismissNewInterfaceNotice}
/>
)
const GeneralSection = () => ( const GeneralSection = () => (
<div class="settings-v2-section"> <div class="settings-v2-section">
<SettingsListV2> <SettingsListV2>
@@ -251,6 +279,7 @@ export const SettingsGeneralV2: Component<{
</div> </div>
</SettingsRowV2> </SettingsRowV2>
<Show when={protocol() === "v1"}>
<SettingsRowV2 <SettingsRowV2
title={language.t("settings.general.row.shell.title")} title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")} description={language.t("settings.general.row.shell.description")}
@@ -271,6 +300,7 @@ export const SettingsGeneralV2: Component<{
}} }}
/> />
</SettingsRowV2> </SettingsRowV2>
</Show>
<SettingsRowV2 <SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")} title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -661,6 +691,14 @@ export const SettingsGeneralV2: Component<{
</div> </div>
<div class="settings-v2-tab-body"> <div class="settings-v2-tab-body">
<Show when={settings.general.layoutTransitionAvailable()}>
<InterfaceSection />
</Show>
<Show when={settings.general.newInterfaceNoticeVisible()}>
<InterfaceNoticeSection />
</Show>
<GeneralSection /> <GeneralSection />
<AppearanceSection /> <AppearanceSection />
@@ -0,0 +1,76 @@
// @ts-nocheck
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
const copy = {
title: "New layout",
badge: "New",
description: "Use the new tabs and home layout. Switch between layouts for a limited time.",
noticeTitle: "You're now using new layout",
noticeDescription: "The previous layout is no longer available",
dismiss: "Dismiss",
}
function Frame(props) {
return <div class="w-[640px] max-w-full">{props.children}</div>
}
function ToggleExample(props) {
const [state, setState] = createStore({ checked: props.checked })
return (
<Frame>
<LayoutTransitionToggle
title={copy.title}
badge={copy.badge}
description={copy.description}
checked={state.checked}
onChange={(checked) => setState("checked", checked)}
/>
</Frame>
)
}
function NoticeExample() {
const [state, setState] = createStore({ dismissed: false })
return (
<Frame>
<Show when={!state.dismissed} fallback={<span class="text-v2-text-text-muted">Notice dismissed</span>}>
<LayoutRetirementNotice
title={copy.noticeTitle}
description={copy.noticeDescription}
dismiss={copy.dismiss}
onDismiss={() => setState("dismissed", true)}
/>
</Show>
</Frame>
)
}
export default {
title: "App/Settings/Layout transition",
id: "app-settings-layout-transition",
component: LayoutTransitionToggle,
}
export const NewLayoutEnabled = {
render: () => <ToggleExample checked />,
}
export const PreviousLayoutEnabled = {
render: () => <ToggleExample checked={false} />,
}
export const PreviousLayoutRetired = {
render: () => <NoticeExample />,
}
export const AllStates = {
render: () => (
<div class="flex flex-col gap-8">
<ToggleExample checked />
<ToggleExample checked={false} />
<NoticeExample />
</div>
),
}
@@ -0,0 +1,54 @@
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
export function LayoutTransitionToggle(props: {
title: string
badge: string
description: string
checked: boolean
onChange: (checked: boolean) => void
}) {
return (
<div class="settings-v2-section">
<div class="settings-v2-interface-feature">
<SettingsListV2>
<SettingsRowV2
title={
<span class="flex items-center gap-2">
{props.title}
<Tag variant="accent">{props.badge}</Tag>
</span>
}
description={props.description}
>
<div data-action="settings-new-layout-designs">
<Switch checked={props.checked} onChange={props.onChange} />
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
</div>
)
}
export function LayoutRetirementNotice(props: {
title: string
description: string
dismiss: string
onDismiss: () => void
}) {
return (
<div class="settings-v2-section">
<SettingsListV2>
<SettingsRowV2 title={props.title} description={props.description}>
<ButtonV2 size="small" variant="ghost-muted" onClick={props.onDismiss}>
{props.dismiss}
</ButtonV2>
</SettingsRowV2>
</SettingsListV2>
</div>
)
}
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Accessor, type Component, For, Show } from "solid-js" import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk" import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider" import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
import { DialogCustomProvider } from "../dialog-custom-provider" import { DialogCustomProvider } from "../dialog-custom-provider"
@@ -36,6 +36,7 @@ export const SettingsProvidersV2: Component<{
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync() const serverSync = useServerSync()
const providers = useProviders(props.directory) const providers = useProviders(props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack }) const providerConnect = useProviderConnectController({ onBack: props.onBack })
@@ -80,7 +81,8 @@ export const SettingsProvidersV2: Component<{
return language.t("settings.providers.tag.other") return language.t("settings.providers.tag.other")
} }
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id) const canDisconnect = (item: ProviderItem) =>
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -93,7 +95,7 @@ export const SettingsProvidersV2: Component<{
} }
const disableProvider = async (providerID: string, name: string) => { const disableProvider = async (providerID: string, name: string) => {
return if (protocol() !== "v1") return
const before = serverSync().data.config.disabled_providers ?? [] const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID] const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next) serverSync().set("config", "disabled_providers", next)
@@ -116,15 +118,20 @@ export const SettingsProvidersV2: Component<{
} }
const disconnect = async (providerID: string, name: string) => { const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSdk().legacy.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name)
return
}
const location = props.directory() ? { directory: props.directory() } : undefined const location = props.directory() ? { directory: props.directory() } : undefined
await serverSdk() await serverSdk()
.api.integration.get({ integrationID: providerID, location }) .currentApi.integration.get({ integrationID: providerID, location })
.then(async (integration) => { .then(async (integration) => {
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? [] const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`) if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all( await Promise.all(
credentials.map((credential) => credentials.map((credential) =>
serverSdk().api.credential.remove({ credentialID: credential.id, location }), serverSdk().currentApi.credential.remove({ credentialID: credential.id, location }),
), ),
) )
showToast({ showToast({
@@ -222,7 +229,7 @@ export const SettingsProvidersV2: Component<{
)} )}
</For> </For>
<Show when={false}> <Show when={protocol() === "v1"}>
<div class="settings-v2-provider-row" data-component="custom-provider-section"> <div class="settings-v2-provider-row" data-component="custom-provider-section">
<div class="settings-v2-provider-lead"> <div class="settings-v2-provider-lead">
<ProviderIcon <ProviderIcon
@@ -10,7 +10,7 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName } from "@/context/server"
import { useServerCollectionController } from "../server/server-management-controller" import { useServerManagementController } from "../dialog-select-server"
import { DialogServerV2 } from "./dialog-server-v2" import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings" import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
@@ -19,16 +19,16 @@ import "./settings-v2.css"
export const SettingsServersV2: Component = () => { export const SettingsServersV2: Component = () => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const domain = useServerCollectionController() const controller = useServerManagementController()
const [store, setStore] = createStore({ filter: "" }) const [store, setStore] = createStore({ filter: "" })
const wslServers = useFilteredWslServers(() => store.filter) const wslServers = useFilteredWslServers(() => store.filter)
const showSearch = createMemo( const showSearch = createMemo(
() => domain.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1, () => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
) )
const filtered = createMemo(() => { const filtered = createMemo(() => {
const items = domain.collection.items().filter((item) => !isWslServer(item)) const items = controller.sortedItems().filter((item) => !isWslServer(item))
const query = store.filter.trim() const query = store.filter.trim()
if (!query) return items if (!query) return items
return fuzzysort return fuzzysort
@@ -39,11 +39,11 @@ export const SettingsServersV2: Component = () => {
}) })
const openAdd = () => { const openAdd = () => {
void dialog.push(() => <DialogServerV2 mode="add" />) dialog.push(() => <DialogServerV2 mode="add" />)
} }
const openEdit = (server: ServerConnection.Http) => { const openEdit = (server: ServerConnection.Http) => {
void dialog.push(() => <DialogServerV2 mode="edit" server={server} />) dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
} }
return ( return (
@@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => {
} }
> >
<SettingsListV2> <SettingsListV2>
<WslServerSettings domain={domain} servers={wslServers} /> <WslServerSettings controller={controller} servers={wslServers} />
<For each={filtered()}> <For each={filtered()}>
{(item) => { {(item) => {
const key = ServerConnection.key(item) const key = ServerConnection.key(item)
const health = () => domain.collection.health()[key] const health = () => controller.status()[key]
const isDefault = () => domain.defaults.key() === key const isDefault = () => controller.defaultKey() === key
return ( return (
<div class="settings-v2-servers-row"> <div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead"> <div class="settings-v2-servers-lead">
@@ -122,10 +122,10 @@ export const SettingsServersV2: Component = () => {
</div> </div>
</div> </div>
<div class="settings-v2-servers-actions"> <div class="settings-v2-servers-actions">
<Show when={domain.defaults.available() && isDefault()}> <Show when={controller.canDefault() && isDefault()}>
<Tag>{language.t("dialog.server.status.default")}</Tag> <Tag>{language.t("dialog.server.status.default")}</Tag>
</Show> </Show>
<ServerRowMenu server={item} domain={domain} onEdit={openEdit} /> <ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
</div> </div>
</div> </div>
) )
@@ -5,7 +5,7 @@ import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js" import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -16,7 +16,7 @@ import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useMcpToggle } from "@/context/mcp" import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk" import { useServerProtocol } from "@/context/server-sdk"
const pluginEmptyMessage = (value: string, file: string): JSXElement => { const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file) const parts = value.split(file)
@@ -251,7 +251,6 @@ function ServerStatusList(props: { state: ServerStatusState }) {
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) { export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync() const sync = useSync()
const sdk = useSDK()
const global = useGlobal() const global = useGlobal()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
@@ -259,6 +258,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const language = useLanguage() const language = useLanguage()
const navigate = useNavigate() const navigate = useNavigate()
const settings = useSettings() const settings = useSettings()
const protocol = useServerProtocol()
const fail = (err: unknown) => { const fail = (err: unknown) => {
showToast({ showToast({
@@ -279,7 +279,9 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
dialogRun += 1 dialogRun += 1
}) })
const sortedServers = createMemo(() => { const sortedServers = createMemo(() => {
const list = global.servers.list() const list = settings.general.newLayoutDesigns()
? global.servers.list()
: global.servers.list().filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
return listServersByHealth(list, server.key, global.servers.health) return listServersByHealth(list, server.key, global.servers.health)
}) })
const toggleMcp = useMcpToggle() const toggleMcp = useMcpToggle()
@@ -289,11 +291,9 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync().data.lsp ?? []) const lspItems = createMemo(() => sync().data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length) const lspCount = createMemo(() => lspItems().length)
const [pluginList] = createResource( const plugins = createMemo(() =>
() => (props.shown() ? sdk().directory : undefined), (sync().data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
(directory) => sdk().api.plugin.list({ location: { directory } }).then((result) => result.data),
) )
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
const pluginCount = createMemo(() => plugins().length) const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json")) const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
@@ -318,11 +318,13 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""} {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")} {language.t("status.popover.tab.mcp")}
</Tabs.Trigger> </Tabs.Trigger>
<Show when={protocol() === "v1"}>
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular"> <Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
{lspCount() > 0 ? `${lspCount()} ` : ""} {lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")} {language.t("status.popover.tab.lsp")}
</Tabs.Trigger> </Tabs.Trigger>
<Show when={true}> </Show>
<Show when={protocol() === "v1"}>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular"> <Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""} {pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")} {language.t("status.popover.tab.plugins")}
@@ -426,7 +428,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
"bg-icon-success-base": status() === "connected", "bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed", "bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled", "bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base": status() === "needs_auth", "bg-icon-warning-base":
status() === "needs_auth" || status() === "needs_client_registration",
}} }}
/> />
<span class="flex flex-col min-w-0 flex-1"> <span class="flex flex-col min-w-0 flex-1">
@@ -458,6 +461,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</div> </div>
</Tabs.Content> </Tabs.Content>
<Show when={protocol() === "v1"}>
<Tabs.Content value="lsp"> <Tabs.Content value="lsp">
<div class="flex flex-col px-2 pb-2"> <div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
@@ -485,8 +489,9 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</div> </div>
</div> </div>
</Tabs.Content> </Tabs.Content>
</Show>
<Show when={true}> <Show when={protocol() === "v1"}>
<Tabs.Content value="plugins"> <Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2"> <div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
@@ -35,6 +35,7 @@ describe("hasNonBlockingServiceIssue", () => {
test("detects MCP failures that do not block chatting", () => { test("detects MCP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false) expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
}) })
@@ -47,6 +48,7 @@ describe("hasNonBlockingServiceIssue", () => {
describe("hasServiceNeedingAttention", () => { describe("hasServiceNeedingAttention", () => {
test("detects MCP states that need user attention", () => { test("detects MCP states that need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true) expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
}) })
test("ignores states that do not need user attention", () => { test("ignores states that do not need user attention", () => {
@@ -2,7 +2,7 @@ import type { LspStatus } from "@/types"
import type { McpServer } from "@opencode-ai/client/promise" import type { McpServer } from "@opencode-ai/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) { export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth") return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
} }
export function hasNonBlockingServiceIssue(input: { export function hasNonBlockingServiceIssue(input: {
+26 -4
View File
@@ -11,11 +11,13 @@ 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 { 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"
import { terminalWriter } from "@/utils/terminal-writer" import { terminalWriter } from "@/utils/terminal-writer"
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url" import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
import { authTokenFromCredentials } from "@/utils/server"
const TOGGLE_TERMINAL_ID = "terminal.toggle" const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -174,8 +176,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 connection = useServerSDK()().server
const directory = sdk().directory const directory = sdk().directory
const url = sdk().url const url = sdk().url
const auth = connection.http
const username = auth?.username ?? "opencode"
const password = auth?.password ?? ""
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, [ const [local, others] = splitProps(props, [
"pty", "pty",
@@ -234,7 +241,7 @@ export const Terminal = (props: TerminalProps) => {
const pushSize = async (cols: number, rows: number) => { const pushSize = async (cols: number, rows: number) => {
return sdk() return sdk()
.api.pty.update({ .currentApi.pty.update({
ptyID: id, ptyID: id,
location: { directory }, location: { directory },
size: { cols, rows }, size: { cols, rows },
@@ -516,7 +523,7 @@ export const Terminal = (props: TerminalProps) => {
const gone = async () => { const gone = async () => {
return sdk() return sdk()
.api.pty.get({ ptyID: id, location: { directory } }) .currentApi.pty.get({ ptyID: id, location: { directory } })
.then((result) => result.data.status === "exited") .then((result) => result.data.status === "exited")
.catch((err) => { .catch((err) => {
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
@@ -526,8 +533,23 @@ export const Terminal = (props: TerminalProps) => {
} }
const connectToken = async () => { const connectToken = async () => {
// TODO: Add PTY tickets when the V2 client exposes a connect-token API. const endpoint = new URL(`/api/pty/${encodeURIComponent(id)}/connect-token`, url)
return undefined endpoint.searchParams.set("location[directory]", directory)
const response = await (platform.fetch ?? globalThis.fetch)(endpoint, {
method: "POST",
headers: {
"x-opencode-ticket": "1",
...(password
? { Authorization: `Basic ${authTokenFromCredentials({ username, password })}` }
: undefined),
},
})
if (response.status === 403)
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
if (!response.ok) throw new Error(`PTY connect ticket failed with ${response.status}`)
const result = (await response.json()) as { data?: { ticket?: string } }
if (!result.data?.ticket) throw new Error("PTY connect ticket response did not include a ticket")
return result.data.ticket
} }
const retry = (err: unknown) => { const retry = (err: unknown) => {
@@ -4,12 +4,12 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query" import { createMutation } from "@tanstack/solid-query"
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 { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers" import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { Session } from "@/types"
import { sessionLabel } from "@/utils/session-title"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover" import { TabPreviewPopover } from "./titlebar-tab-popover"
import "./titlebar-tab-nav.css" import "./titlebar-tab-nav.css"
@@ -21,7 +21,7 @@ export function TabNavItem(props: {
ref?: Ref<HTMLDivElement> ref?: Ref<HTMLDivElement>
href: string href: string
server: ServerConnection.Key server: ServerConnection.Key
session: () => SessionInfo | undefined session: () => Session | undefined
fallbackTitle?: string fallbackTitle?: string
onRename: (title: string) => Promise<void> onRename: (title: string) => Promise<void>
onClose: () => void onClose: () => void
@@ -57,19 +57,19 @@ export function TabNavItem(props: {
}) })
const title = createMemo(() => { const title = createMemo(() => {
const session = props.session() const session = props.session()
return session ? sessionLabel(session) : props.fallbackTitle return session ? displayLabel(session) : props.fallbackTitle
}) })
const projectName = createMemo(() => { const projectName = createMemo(() => {
const session = props.session() const session = props.session()
if (!session) return if (!session) return
return displayName(project() ?? { worktree: session.location.directory }) return displayName(project() ?? { worktree: session.directory })
}) })
const previewPath = createMemo(() => { const previewPath = createMemo(() => {
const session = props.session() const session = props.session()
if (!session) return if (!session) return
const home = serverCtx()?.sync.data.path.home const home = serverCtx()?.sync.data.path.home
return home ? session.location.directory.replace(home, "~") : session.location.directory return home ? session.directory.replace(home, "~") : session.directory
}) })
// Only label the server when multiple servers are connected. // Only label the server when multiple servers are connected.
const serverLabel = createMemo(() => { const serverLabel = createMemo(() => {
@@ -235,7 +235,7 @@ export function TabNavItem(props: {
{(session) => ( {(session) => (
<SessionTabAvatar <SessionTabAvatar
project={project()} project={project()}
directory={session().location.directory} directory={session().directory}
sessionId={session().id} sessionId={session().id}
server={props.server} server={props.server}
/> />
@@ -306,7 +306,7 @@ export function TabNavItem(props: {
}} }}
data={{ data={{
projectName: projectName(), projectName: projectName(),
title: props.session()?.title, title: title(),
path: previewPath(), path: previewPath(),
serverName: serverLabel(), serverName: serverLabel(),
}} }}
@@ -19,7 +19,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order" import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { Session } from "@/types"
function SessionTabSlot(props: { function SessionTabSlot(props: {
tab: SessionTab tab: SessionTab
@@ -27,7 +27,7 @@ function SessionTabSlot(props: {
index: () => number index: () => number
active: () => boolean active: () => boolean
forceTruncate: boolean forceTruncate: boolean
session: () => SessionInfo | undefined session: () => Session | undefined
fallbackTitle?: string fallbackTitle?: string
onRename: (title: string) => Promise<void> onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void onNavigate: (element: HTMLDivElement) => void
@@ -105,7 +105,7 @@ function SessionTabEntry(props: {
ctx.sync.session.remember({ ...value, title }) ctx.sync.session.remember({ ...value, title })
try { try {
await ctx.sdk.api.session.rename({ sessionID: value.id, title }) await ctx.sdk.currentApi.session.rename({ sessionID: value.id, title })
} catch (err) { } catch (err) {
const current = session() const current = session()
const currentCtx = props.serverCtx() const currentCtx = props.serverCtx()
@@ -127,7 +127,7 @@ function SessionTabEntry(props: {
createRoot((dispose) => { createRoot((dispose) => {
try { try {
void ctx.sync void ctx.sync
.ensureDirSyncContext(value.location.directory) .ensureDirSyncContext(value.directory)
.session.sync(value.id) .session.sync(value.id)
.catch(() => {}) .catch(() => {})
.finally(dispose) .finally(dispose)
@@ -144,7 +144,7 @@ function SessionTabEntry(props: {
const current = sdk() const current = sdk()
if (!current) return if (!current) return
createTabPromptState(tabs, props.tab, current.scope, { createTabPromptState(tabs, props.tab, current.scope, {
dir: base64Encode(value.location.directory), dir: base64Encode(value.directory),
id: value.id, id: value.id,
}) })
}) })
+4 -2
View File
@@ -27,6 +27,7 @@ import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt" import type { PromptSession } from "@/context/prompt"
import "./titlebar.css" import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind" import { newTabTooltipKeybind } from "./command-tooltip-keybind"
import { normalizeSessionInfo } from "@/utils/session"
const legacyTitlebarHeight = 40 const legacyTitlebarHeight = 40
const v2TitlebarHeight = 36 const v2TitlebarHeight = 36
@@ -191,8 +192,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
}, },
({ route, sdk }) => ({ route, sdk }) =>
sdk.api.session sdk.currentApi.session
.get({ sessionID: route.sessionId }) .get({ sessionID: route.sessionId })
.then(normalizeSessionInfo)
.catch(() => {}), .catch(() => {}),
) )
@@ -254,7 +256,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
sessionId: activeSession.id, sessionId: activeSession.id,
} }
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current() const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model) tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
return return
} }
+5
View File
@@ -248,6 +248,11 @@ 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
+16 -8
View File
@@ -1,11 +1,11 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import type { Message, Part } from "@/types" import type { Message, Part, Session } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise"
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"
import type { createServerSyncContextInner } from "./server-sync" import type { createServerSyncContextInner } from "./server-sync"
import type { State } from "./global-sync/types" import type { State } from "./global-sync/types"
import { normalizeSessionInfo } from "@/utils/session"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const sessionFields = new Set([ const sessionFields = new Set([
@@ -26,6 +26,7 @@ export const createDirSyncContext = (
serverSync: ReturnType<typeof createServerSyncContextInner>, serverSync: ReturnType<typeof createServerSyncContextInner>,
serverSDK: ReturnType<typeof createServerSdkContext>, serverSDK: ReturnType<typeof createServerSdkContext>,
) => { ) => {
const client = serverSDK.createClient({ directory, throwOnError: true })
const current = createMemo(() => serverSync.child(directory, { mcp: true })) const current = createMemo(() => serverSync.child(directory, { mcp: true }))
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const data = new Proxy({} as State, { const data = new Proxy({} as State, {
@@ -46,7 +47,7 @@ export const createDirSyncContext = (
const index = (sessionID: string) => { const index = (sessionID: string) => {
const session = serverSync.session.get(sessionID) const session = serverSync.session.get(sessionID)
if (!session || session.location.directory !== directory) return if (!session || session.directory !== directory) return
const [store, setStore] = current() const [store, setStore] = current()
const result = Binary.search(store.session, session.id, (item) => item.id) const result = Binary.search(store.session, session.id, (item) => item.id)
if (result.found) { if (result.found) {
@@ -74,13 +75,13 @@ 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: SessionInfo) { remember(session: Session) {
serverSync.session.remember(session) serverSync.session.remember(session)
index(session.id) index(session.id)
}, },
get(sessionID: string) { get(sessionID: string) {
const session = serverSync.session.get(sessionID) const session = serverSync.session.get(sessionID)
if (session?.location.directory === directory) return session if (session?.directory === directory) return session
}, },
optimistic: { optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) { add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
@@ -123,8 +124,9 @@ export const createDirSyncContext = (
fetch: async (count = 10) => { fetch: async (count = 10) => {
const [store, setStore] = current() const [store, setStore] = current()
setStore("limit", (value) => value + count) setStore("limit", (value) => value + count)
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" }) const response = await serverSDK.currentApi.session.list({ directory, limit: store.limit, order: "desc" })
const sessions = response.data const sessions = response.data
.map(normalizeSessionInfo)
.sort((a, b) => cmp(a.id, b.id)) .sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit) .slice(0, store.limit)
sessions.forEach(serverSync.session.remember) sessions.forEach(serverSync.session.remember)
@@ -132,8 +134,14 @@ export const createDirSyncContext = (
}, },
more: createMemo(() => current()[0].session.length >= current()[0].limit), more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => { archive: async (sessionID: string) => {
// TODO: Restore archiving when the V2 client exposes a session archive API. await serverSDK.legacy.session.archive(sessionID, directory)
void sessionID current()[1](
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (session) => session.id)
if (match.found) draft.splice(match.index, 1)
}),
)
}, },
}, },
mcp: { mcp: {
+10 -3
View File
@@ -81,7 +81,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
normalizeDir: path.normalizeDir, normalizeDir: path.normalizeDir,
list: (dir) => list: (dir) =>
sdk() sdk()
.api.file.list({ path: dir, location: { directory: scope() } }) .currentApi.file.list({ path: dir, location: { directory: scope() } })
.then((x) => .then((x) =>
x.data.map((entry) => ({ x.data.map((entry) => ({
...entry, ...entry,
@@ -188,7 +188,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setLoading(file) setLoading(file)
const promise = sdk() const promise = sdk()
.api.file.read({ path: file, location: { directory } }) .currentApi.file.read({ path: file, location: { directory } })
.then((data) => { .then((data) => {
if (scope() !== directory) return if (scope() !== directory) return
const content = { type: "text" as const, content: new TextDecoder().decode(data) } const content = { type: "text" as const, content: new TextDecoder().decode(data) }
@@ -212,7 +212,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) => const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
serverSDK() serverSDK()
.api.file.find( .currentApi.file.find(
{ {
location: { directory: sdk().directory }, location: { directory: sdk().directory },
query, query,
@@ -286,6 +286,13 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
children: tree.children, children: tree.children,
expand: tree.expandDir, expand: tree.expandDir,
collapse: tree.collapseDir, collapse: tree.collapseDir,
toggle(input: string) {
if (tree.dirState(input)?.expanded) {
tree.collapseDir(input)
return
}
tree.expandDir(input)
},
}, },
get, get,
load, load,
@@ -1,8 +1,12 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query" import { QueryClient } from "@tanstack/solid-query"
import type { Config, Project } from "@/types"
import type { LegacyCapabilities } from "@/utils/server-compat"
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise" import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { import {
bootstrapDirectory,
loadAgentsQuery, loadAgentsQuery,
loadCommands, loadCommands,
loadPathQuery, loadPathQuery,
@@ -10,19 +14,139 @@ import {
loadProvidersQuery, loadProvidersQuery,
loadReferencesQuery, loadReferencesQuery,
} from "./bootstrap" } from "./bootstrap"
import type { State, VcsCache } from "./types"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
type ProjectApi = ServerApi["project"] type ProjectApi = ServerApi["project"]
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
const api = {
agent: { list: async () => ({ location: {}, data: [] }) },
provider: { list: async () => ({ location: {}, data: [] }) },
model: {
list: async () => ({ location: {}, data: [] }),
default: async () => ({ location: {}, data: null }),
},
permission: { request: { list: async () => ({ location: {}, data: [] }) } },
project: {
list: async () => [],
current: async () => ({ id: "project", directory: "/project" }),
},
question: { request: { list: async () => ({ location: {}, data: [] }) } },
reference: { list: async () => ({ location: {}, data: [] }) },
vcs: { get: async () => ({ location: {}, data: {} }) },
} as unknown as ServerApi
function directoryState() {
return createStore<State>({
status: "loading",
agent: [],
command: [],
reference: [],
project: "",
projectMeta: undefined,
icon: undefined,
provider_ready: true,
provider,
config: {},
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
session: [],
sessionTotal: 0,
session_status: {},
session_working(id: string) {
return this.session_status[id]?.type !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp_ready: true,
mcp: {},
mcp_resource: {},
lsp_ready: true,
lsp: [],
vcs: undefined,
limit: 5,
message: {},
session_message: {},
part: {},
part_text_accum_delta: {},
})
}
describe("bootstrapDirectory", () => {
test("uses current MCP endpoints while retaining unsupported v1 directory reads", async () => {
const mcpReads: string[] = []
const [store, setStore] = directoryState()
const currentApi = {
...api,
command: {
list: async () => {
mcpReads.push("command")
return { location: {}, data: [] }
},
},
mcp: {
list: async () => {
mcpReads.push("status")
return { location: {}, data: [] }
},
resource: {
catalog: async () => {
mcpReads.push("resource")
return { location: {}, data: { resources: [], templates: [] } }
},
},
},
} as unknown as ServerApi
await bootstrapDirectory({
directory: "/project",
scope: ServerScope.local,
mcp: true,
global: {
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
project: [{ id: "project", worktree: "/project" } as Project],
provider,
},
legacy: { config: { directory: async () => ({}) } } as unknown as LegacyCapabilities,
api: currentApi,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
loadSessions() {},
translate: (key) => key,
queryClient: new QueryClient(),
protocol: Promise.resolve("v1"),
})
expect(store.status).toBe("partial")
await new Promise((resolve) => setTimeout(resolve, 80))
expect(store.status).toBe("complete")
expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
})
})
describe("query keys", () => { describe("query keys", () => {
test("partitions identical directories by server scope", () => { test("partitions identical directories by server scope", () => {
const location = {} as Parameters<typeof loadPathQuery>[2]
const api = {} as CatalogApi const api = {} as CatalogApi
const location = {} as ServerApi["location"]
const remote = "https://debian.example" as typeof ServerScope.local const remote = "https://debian.example" as typeof ServerScope.local
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual(["local", "/repo", "path"]) expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual([
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual(["https://debian.example", "/repo", "path"]) "local",
"/repo",
"path",
])
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual([
"https://debian.example",
"/repo",
"path",
])
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"]) expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
}) })
@@ -57,21 +181,6 @@ describe("query keys", () => {
expect(result.connected).toEqual(["openai"]) expect(result.connected).toEqual(["openai"])
}) })
test("loads current location metadata", async () => {
const calls: unknown[] = []
const api = {
get: async (input: unknown) => {
calls.push(input)
return { directory: "/repo/subpath", project: { id: "project", directory: "/repo" } }
},
} as ServerApi["location"]
const result = await new QueryClient().fetchQuery(loadPathQuery(ServerScope.local, "/repo/subpath", api))
expect(calls).toEqual([{ location: { directory: "/repo/subpath" } }])
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
})
test("loads agents from the current location-scoped endpoint", async () => { test("loads agents from the current location-scoped endpoint", async () => {
const calls: unknown[] = [] const calls: unknown[] = []
const api = { const api = {
@@ -1,9 +1,14 @@
import type { import type {
Config, Config,
Path, Path,
PermissionRequest,
Project, Project,
ProviderAuthResponse, ProviderAuthResponse,
QuestionRequest,
ReferenceInfo,
Session,
} from "@/types" } from "@/types"
import type { LegacyCapabilities } from "@/utils/server-compat"
import type { import type {
AgentListInput, AgentListInput,
AgentListOutput, AgentListOutput,
@@ -13,16 +18,12 @@ import type {
CommandListOutput, CommandListOutput,
LocationGetInput, LocationGetInput,
LocationGetOutput, LocationGetOutput,
PermissionRequest,
ProjectCurrentInput, ProjectCurrentInput,
ProjectCurrentOutput, ProjectCurrentOutput,
ProjectListOutput, ProjectListOutput,
ReferenceListInput, ReferenceListInput,
ReferenceListOutput, ReferenceListOutput,
ReferenceInfo,
QuestionRequest,
SessionApi, SessionApi,
SessionInfo,
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
@@ -34,6 +35,7 @@ import type { ServerSession } from "../server-session"
import { import {
cmp, cmp,
normalizeAgentList, normalizeAgentList,
normalizePermissionRequest,
normalizeProjectInfo, normalizeProjectInfo,
normalizeProviderList, normalizeProviderList,
} from "./utils" } from "./utils"
@@ -42,6 +44,8 @@ import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope" import { ScopedKey, type ServerScope } from "@/utils/server-scope"
import { normalizeSessionInfo } from "@/utils/session"
import type { ServerProtocol } from "@/utils/server-protocol"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
type GlobalStore = { type GlobalStore = {
@@ -103,11 +107,11 @@ function showErrors(input: {
}) })
} }
export const loadGlobalConfigQuery = (scope: ServerScope) => export const loadGlobalConfigQuery = (scope: ServerScope, legacy: LegacyCapabilities, enabled = true) =>
queryOptions({ queryOptions({
queryKey: [scope, "config"], queryKey: [scope, "config"],
// TODO: Restore config loading when the V2 client exposes a config API. queryFn: () => retry(() => legacy.config.global()),
queryFn: async (): Promise<Config> => ({}), enabled,
}) })
type ProjectApi = { type ProjectApi = {
@@ -138,7 +142,9 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
}) })
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
legacy: LegacyCapabilities
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi } serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
protocol?: Promise<ServerProtocol>
scope: ServerScope scope: ServerScope
requestFailedTitle: string requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
@@ -146,18 +152,22 @@ export async function bootstrapGlobal(input: {
setGlobalStore: SetStoreFunction<GlobalStore> setGlobalStore: SetStoreFunction<GlobalStore>
queryClient: QueryClient queryClient: QueryClient
}) { }) {
const protocol = await input.protocol
const slow = [ const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)), protocol === "v1" && (() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.legacy))),
() => () =>
input.queryClient.fetchQuery( input.queryClient.fetchQuery(
loadProvidersQuery(input.scope, null, input.serverAPI), loadProvidersQuery(input.scope, null, input.serverAPI),
), ),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)), () =>
input.queryClient.fetchQuery(
loadPathQuery(input.scope, null, input.serverAPI.location),
),
() => () =>
input.queryClient input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)) .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
.then((data) => input.setGlobalStore("project", data)), .then((data) => input.setGlobalStore("project", data)),
] ].filter(Boolean) as Array<() => Promise<unknown>>
await runAll(slow) await runAll(slow)
// showErrors({ // showErrors({
// errors: errors(), // errors: errors(),
@@ -181,7 +191,7 @@ function projectID(directory: string, projects: Project[]) {
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
} }
function mergeSession(setStore: SetStoreFunction<State>, session: SessionInfo) { function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
setStore("session", (list) => { setStore("session", (list) => {
const next = list.slice() const next = list.slice()
const idx = next.findIndex((item) => item.id >= session.id) const idx = next.findIndex((item) => item.id >= session.id)
@@ -206,7 +216,9 @@ function warmSessions(input: {
if (ids.length === 0) return Promise.resolve() if (ids.length === 0) return Promise.resolve()
return Promise.all( return Promise.all(
ids.map((sessionID) => ids.map((sessionID) =>
retry(() => input.api.get({ sessionID })).then((session) => mergeSession(input.setStore, session)), retry(() => input.api.get({ sessionID })).then((session) =>
mergeSession(input.setStore, normalizeSessionInfo(session)),
),
), ),
).then(() => undefined) ).then(() => undefined)
} }
@@ -267,7 +279,7 @@ export const loadPathQuery = (
queryOptions<Path>({ queryOptions<Path>({
queryKey: [scope, directory, "path"], queryKey: [scope, directory, "path"],
queryFn: () => queryFn: () =>
api.get(directory ? { location: { directory } } : undefined).then((location) => ({ retry(() => api.get(directory ? { location: { directory } } : undefined)).then((location) => ({
state: "", state: "",
config: "", config: "",
worktree: location.project.directory, worktree: location.project.directory,
@@ -292,6 +304,7 @@ export async function bootstrapDirectory(input: {
directory: string directory: string
scope: ServerScope scope: ServerScope
mcp: boolean mcp: boolean
legacy: LegacyCapabilities
api: CatalogApi & { api: CatalogApi & {
readonly agent: AgentListApi readonly agent: AgentListApi
readonly command: CommandListApi readonly command: CommandListApi
@@ -317,6 +330,7 @@ export async function bootstrapDirectory(input: {
} }
queryClient: QueryClient queryClient: QueryClient
session?: ServerSession session?: ServerSession
protocol?: Promise<ServerProtocol>
}) { }) {
const loading = input.store.status !== "complete" const loading = input.store.status !== "complete"
const seededProject = projectID(input.directory, input.global.project) const seededProject = projectID(input.directory, input.global.project)
@@ -338,6 +352,13 @@ export async function bootstrapDirectory(input: {
input.queryClient input.queryClient
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent)) .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
.then((data) => input.setStore("agent", data)), .then((data) => input.setStore("agent", data)),
(await input.protocol) === "v1" &&
(() =>
retry(() =>
input.legacy.config
.directory(input.directory)
.then((config) => input.setStore("config", reconcile(config, { merge: false }))),
)),
!seededProject && !seededProject &&
(() => (() =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) => retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
@@ -346,7 +367,9 @@ export async function bootstrapDirectory(input: {
!seededPath && !seededPath &&
(() => (() =>
input.queryClient input.queryClient
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location)) .ensureQueryData(
loadPathQuery(input.scope, input.directory, input.api.location),
)
.then((data) => { .then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project) const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next) if (next) input.setStore("project", next)
@@ -364,7 +387,7 @@ export async function bootstrapDirectory(input: {
retry(() => retry(() =>
input.api.permission.request input.api.permission.request
.list({ location: { directory: input.directory } }) .list({ location: { directory: input.directory } })
.then((result) => result.data) .then((result) => result.data.map(normalizePermissionRequest))
.then((permissions) => { .then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID) const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession( const grouped = groupBySession(
@@ -378,7 +401,7 @@ export async function bootstrapDirectory(input: {
const current = input.session?.data.permission ?? input.store.permission const current = input.session?.data.permission ?? input.store.permission
for (const sessionID of Object.keys(current)) { for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue if (input.session?.get(sessionID)?.directory !== input.directory) continue
if (input.session) input.session.set("permission", sessionID, []) if (input.session) input.session.set("permission", sessionID, [])
if (!input.session) input.setStore("permission", sessionID, []) if (!input.session) input.setStore("permission", sessionID, [])
} }
@@ -412,7 +435,7 @@ export async function bootstrapDirectory(input: {
const current = input.session?.data.question ?? input.store.question const current = input.session?.data.question ?? input.store.question
for (const sessionID of Object.keys(current)) { for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue if (input.session?.get(sessionID)?.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, []) if (input.session) input.session.set("question", sessionID, [])
if (!input.session) input.setStore("question", sessionID, []) if (!input.session) input.setStore("question", sessionID, [])
} }
@@ -191,7 +191,10 @@ export function createChildStoreManager(input: {
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() })) const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() })) const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() })) const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() })) const lspQuery = useQuery(() => {
const options = input.queryOptions.lsp(key)
return { ...options, enabled: options.enabled !== false && instanceQueriesEnabled() }
})
const providerQuery = useQuery(() => ({ const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key), ...input.queryOptions.providers(key),
enabled: instanceQueriesEnabled(), enabled: instanceQueriesEnabled(),
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message, Part, Project } from "@/types" import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@/types"
import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { State } from "./types" import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
@@ -14,7 +13,7 @@ const rootSession = (input: { id: string; parentID?: string; archived?: number }
updated: 1, updated: 1,
archived: input.archived, archived: input.archived,
}, },
}) as SessionInfo }) as Session
const userMessage = (id: string, sessionID: string) => const userMessage = (id: string, sessionID: string) =>
({ ({
@@ -39,10 +38,10 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
({ ({
id, id,
sessionID, sessionID,
action: title, permission: title,
resources: ["*"], patterns: ["*"],
metadata: {}, metadata: {},
save: [], always: [],
}) as PermissionRequest }) as PermissionRequest
const questionRequest = (id: string, sessionID: string, title = id) => const questionRequest = (id: string, sessionID: string, title = id) =>
@@ -513,7 +512,7 @@ describe("applyDirectoryEvent", () => {
directory: "/tmp", directory: "/tmp",
loadLsp() {}, loadLsp() {},
}) })
expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.action).toBe("updated") expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.permission).toBe("updated")
applyDirectoryEvent({ applyDirectoryEvent({
event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } }, event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } },
@@ -3,10 +3,14 @@ import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/
import type { import type {
Message, Message,
Part, Part,
PermissionRequest,
Project, Project,
QuestionRequest,
Session,
SessionStatus,
Todo, Todo,
} from "@/types" } from "@/types"
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache" import { dropSessionCaches } from "./session-cache"
@@ -75,7 +79,7 @@ function cleanupSessionCaches(
export function cleanupDroppedSessionCaches( export function cleanupDroppedSessionCaches(
store: Store<State>, store: Store<State>,
setStore: SetStoreFunction<State>, setStore: SetStoreFunction<State>,
next: SessionInfo[], next: Session[],
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) { ) {
const keep = new Set(next.map((item) => item.id)) const keep = new Set(next.map((item) => item.id))
@@ -124,7 +128,7 @@ export function applyDirectoryEvent(input: {
return return
} }
case "session.created": { case "session.created": {
const info = (event.properties as { info: SessionInfo }).info const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id) const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (result.found) { if (result.found) {
input.setStore("session", result.index, reconcile(info)) input.setStore("session", result.index, reconcile(info))
@@ -139,7 +143,7 @@ export function applyDirectoryEvent(input: {
break break
} }
case "session.updated": { case "session.updated": {
const info = (event.properties as { info: SessionInfo }).info const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id) const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (info.time.archived) { if (info.time.archived) {
if (!result.found) break if (!result.found) break
@@ -167,7 +171,7 @@ export function applyDirectoryEvent(input: {
break break
} }
case "session.deleted": { case "session.deleted": {
const properties = event.properties as { sessionID?: string; info?: SessionInfo } const properties = event.properties as { sessionID?: string; info?: Session }
const sessionID = properties.info?.id ?? properties.sessionID const sessionID = properties.info?.id ?? properties.sessionID
if (!sessionID) break if (!sessionID) break
const result = Binary.search(input.store.session, sessionID, (s) => s.id) const result = Binary.search(input.store.session, sessionID, (s) => s.id)
@@ -197,7 +201,7 @@ export function applyDirectoryEvent(input: {
break break
} }
case "session.usage.updated": { case "session.usage.updated": {
const properties = event.properties as Pick<SessionInfo, "cost" | "tokens"> & { sessionID: string } const properties = event.properties as Pick<Session, "cost" | "tokens"> & { sessionID: string }
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
if (!result.found) break if (!result.found) break
input.setStore("session", result.index, (session) => ({ input.setStore("session", result.index, (session) => ({
@@ -233,8 +237,9 @@ export function applyDirectoryEvent(input: {
input.setStore("session", result.index, (session) => ({ input.setStore("session", result.index, (session) => ({
...session, ...session,
projectID: properties.projectID ?? session.projectID, projectID: properties.projectID ?? session.projectID,
location: properties.location, workspaceID: properties.location.workspaceID,
subpath: properties.subpath, directory: properties.location.directory,
path: properties.subpath,
time: { ...session.time, updated: Date.now() }, time: { ...session.time, updated: Date.now() },
})) }))
break break
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { SessionV2Info } from "@/types"
import { import {
applyHomeSessionEvent, applyHomeSessionEvent,
appendHomeSessionEvent, appendHomeSessionEvent,
@@ -33,7 +33,7 @@ describe("Home V2 session index", () => {
const calls: unknown[] = [] const calls: unknown[] = []
const result = await loadHomeSessionIndex(async (input) => { const result = await loadHomeSessionIndex(async (input) => {
calls.push(input) calls.push(input)
return { data: [session({ id: "root" })], cursor: {} } return { data: { data: [session({ id: "root" })], cursor: {} } }
}) })
expect(result.sessions).toHaveLength(1) expect(result.sessions).toHaveLength(1)
@@ -48,11 +48,15 @@ describe("Home V2 session index", () => {
calls.push({ input, signal: options.signal }) calls.push({ input, signal: options.signal })
if (!("cursor" in input)) { if (!("cursor" in input)) {
return { return {
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session({ id: `page-1-${index}` })), data: {
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
session({ id: `page-1-${index}` }),
),
cursor: { next: "next-page" }, cursor: { next: "next-page" },
},
} }
} }
return { data: [session({ id: "page-2" })], cursor: {} } return { data: { data: [session({ id: "page-2" })], cursor: {} } }
}, },
0, 0,
controller.signal, controller.signal,
@@ -72,7 +76,7 @@ describe("Home V2 session index", () => {
const activeNull = { const activeNull = {
...session({ id: "active-null", updated: 20 }), ...session({ id: "active-null", updated: 20 }),
time: { created: 1, updated: 20, archived: null }, time: { created: 1, updated: 20, archived: null },
} as unknown as SessionInfo } as unknown as SessionV2Info
const result = parseHomeSessionIndex([ const result = parseHomeSessionIndex([
session({ id: "root", updated: 30 }), session({ id: "root", updated: 30 }),
activeNull, activeNull,
@@ -83,7 +87,9 @@ describe("Home V2 session index", () => {
expect(result).toEqual([ expect(result).toEqual([
expect.objectContaining({ expect.objectContaining({
id: "root", id: "root",
location: { directory: "/project" }, slug: "root",
version: "",
directory: "/project",
projectID: "project", projectID: "project",
title: "root", title: "root",
time: { created: 1, updated: 30 }, time: { created: 1, updated: 30 },
@@ -99,17 +105,17 @@ describe("Home V2 session index", () => {
const now = 10 * 60 * 60 * 1000 const now = 10 * 60 * 60 * 1000
const sessions = Array.from({ length: 80 }, (_, index) => ({ const sessions = Array.from({ length: 80 }, (_, index) => ({
...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0], ...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
location: { directory: index % 2 === 0 ? "/one" : "/two" }, directory: index % 2 === 0 ? "/one" : "/two",
})) }))
const retained = retainHomeSessions(sessions, 10, now) const retained = retainHomeSessions(sessions, 10, now)
expect(retained.filter((item) => item.location.directory === "/one")).toHaveLength(10) expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
expect(retained.filter((item) => item.location.directory === "/two")).toHaveLength(10) expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
}) })
test("replays session events over the loaded index", () => { test("replays session events over the loaded index", () => {
const initial = parseHomeSessionIndex([session({ id: "old" })]) const initial = parseHomeSessionIndex([session({ id: "old" })])
const created = { ...initial[0], id: "new", title: "new", time: { created: 2, updated: 2 } } const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
const afterCreate = applyHomeSessionEvent(initial, { const afterCreate = applyHomeSessionEvent(initial, {
type: "session.created", type: "session.created",
@@ -139,8 +145,10 @@ describe("Home V2 session index", () => {
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current") expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
}) })
test("refetches after reconnect", () => { test("refetches after reconnect, disposal, and session moves", () => {
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false }) expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true }) expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
}) })
}) })
@@ -1,5 +1,4 @@
import type { Event } from "@/types" import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@/types"
import type { SessionInfo, SessionsResponse } from "@opencode-ai/client/promise"
import type { QueryClient } from "@tanstack/solid-query" import type { QueryClient } from "@tanstack/solid-query"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
@@ -8,29 +7,31 @@ export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
export type HomeSessionEvent = { export type HomeSessionEvent = {
type: "session.created" | "session.updated" | "session.deleted" type: "session.created" | "session.updated" | "session.deleted"
properties: { sessionID: string; info?: SessionInfo } properties: { sessionID: string; info: Session }
} }
export type HomeSessionEvents = { export type HomeSessionEvents = {
sequence: number sequence: number
entries: Array<{ sequence: number; event: HomeSessionEvent }> entries: Array<{ sequence: number; event: HomeSessionEvent }>
} }
export type HomeSessionIndex = { export type HomeSessionIndex = {
sessions: SessionInfo[] sessions: Session[]
eventSequence: number eventSequence: number
} }
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
type HomeSessionPage = { data?: V2SessionListResponse }
export async function loadHomeSessionIndex( export async function loadHomeSessionIndex(
list: ( list: (
input: { limit: number; order: "desc"; cursor?: string }, input: { limit: number; order: "desc"; cursor?: string },
options: { signal?: AbortSignal }, options: { signal?: AbortSignal },
) => Promise<SessionsResponse>, ) => Promise<HomeSessionPage>,
eventSequence = 0, eventSequence = 0,
signal?: AbortSignal, signal?: AbortSignal,
) { ) {
const data: SessionInfo[] = [] const data: SessionV2Info[] = []
let cursor: string | undefined let cursor: string | undefined
for (;;) { for (;;) {
@@ -42,7 +43,7 @@ export async function loadHomeSessionIndex(
}, },
{ signal }, { signal },
) )
const page = response const page = response.data!
data.push(...page.data) data.push(...page.data)
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next) if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
return { sessions: parseHomeSessionIndex(data), eventSequence } return { sessions: parseHomeSessionIndex(data), eventSequence }
@@ -74,7 +75,10 @@ export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, ev
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) { export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
if (event === "server.connected") return { connected: true, refetch: connected } if (event === "server.connected") return { connected: true, refetch: connected }
return { connected, refetch: false } return {
connected,
refetch: event === "global.disposed" || event === "session.next.moved",
}
} }
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) { export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
@@ -125,28 +129,23 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st
// current V2 API orders by creation time and cannot filter roots, archives, or // current V2 API orders by creation time and cannot filter roots, archives, or
// multiple directories. A bounded page could omit an old session updated today. // multiple directories. A bounded page could omit an old session updated today.
// Once released, use client.v2.project.list() and client.v2.session.list({ // Once released, use client.v2.project.list() and client.v2.session.list({
// parentID: null, order: "desc" }) and replace this full-table scan. // parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
export function parseHomeSessionIndex(sessions: SessionInfo[]): SessionInfo[] { export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
return sessions.flatMap((item) => { return sessions.flatMap((item) => {
if (item.parentID || typeof item.time.archived === "number") return [] if (item.parentID || typeof item.time.archived === "number") return []
return [item] return [toLegacySummary(item)]
}) })
} }
export function retainHomeSessions(sessions: SessionInfo[], limit: number, now: number) { export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
const grouped = Map.groupBy(sessions, (session) => pathKey(session.location.directory)) const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now })) return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
} }
export function applyHomeSessionEvent(sessions: SessionInfo[], event: HomeSessionEvent) { export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
const info = event.properties.info const info = event.properties.info
const index = sessions.findIndex((session) => session.id === (info?.id ?? event.properties.sessionID)) const index = sessions.findIndex((session) => session.id === info.id)
if (event.type === "session.deleted") { if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
if (index === -1) return sessions
return sessions.toSpliced(index, 1)
}
if (!info) return sessions
if (info.parentID || typeof info.time.archived === "number") {
if (index === -1) return sessions if (index === -1) return sessions
return sessions.toSpliced(index, 1) return sessions.toSpliced(index, 1)
} }
@@ -154,3 +153,22 @@ export function applyHomeSessionEvent(sessions: SessionInfo[], event: HomeSessio
if (index === -1) return [...sessions, info] if (index === -1) return [...sessions, info]
return sessions.with(index, info) return sessions.with(index, info)
} }
function toLegacySummary(session: SessionV2Info): Session {
return {
id: session.id,
slug: session.id,
projectID: session.projectID,
workspaceID: session.location.workspaceID,
directory: session.location.directory,
path: session.subpath,
parentID: session.parentID,
cost: session.cost,
tokens: session.tokens,
title: session.title,
agent: session.agent,
model: session.model,
version: "",
time: session.time,
}
}
@@ -13,6 +13,7 @@ export async function toggleMcp(input: {
needs_auth: input.authenticate, needs_auth: input.authenticate,
disabled: input.connect, disabled: input.connect,
failed: input.connect, failed: input.connect,
needs_client_registration: input.connect,
}[input.status]() }[input.status]()
await input.refresh() await input.refresh()
} }
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message, Part, Todo } from "@/types" import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types"
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -1,5 +1,4 @@
import type { Message, Part, Todo } from "@/types" import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types"
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionMessageInfo } from "@opencode-ai/client/promise" import type { SessionMessageInfo } from "@opencode-ai/client/promise"
@@ -1,4 +1,5 @@
import type { SessionApi } from "@opencode-ai/client/promise" import type { SessionApi } from "@opencode-ai/client/promise"
import { normalizeSessionInfo } from "@/utils/session"
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) { export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
const result = await input.api.list({ const result = await input.api.list({
@@ -8,7 +9,7 @@ export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; d
order: "desc", order: "desc",
}) })
return { return {
data: result.data, data: result.data.map(normalizeSessionInfo),
limit: input.limit, limit: input.limit,
limited: true, limited: true,
} as const } as const
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise" import type { PermissionRequest, Session } from "@/types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) => const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
@@ -11,7 +11,7 @@ const session = (input: { id: string; parentID?: string; created: number; update
updated: input.updated, updated: input.updated,
archived: input.archived, archived: input.archived,
}, },
}) as SessionInfo }) as Session
describe("trimSessions", () => { describe("trimSessions", () => {
test("keeps base roots and recent roots beyond the limit", () => { test("keeps base roots and recent roots beyond the limit", () => {
@@ -1,22 +1,21 @@
import type { SessionInfo } from "@opencode-ai/client/promise" import type { PermissionRequest, Session } from "@/types"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import { cmp } from "./utils" import { cmp } from "./utils"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types" import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
export function sessionUpdatedAt(session: SessionInfo) { export function sessionUpdatedAt(session: Session) {
return session.time.updated ?? session.time.created return session.time.updated ?? session.time.created
} }
export function compareSessionRecent(a: SessionInfo, b: SessionInfo) { export function compareSessionRecent(a: Session, b: Session) {
const aUpdated = sessionUpdatedAt(a) const aUpdated = sessionUpdatedAt(a)
const bUpdated = sessionUpdatedAt(b) const bUpdated = sessionUpdatedAt(b)
if (aUpdated !== bUpdated) return bUpdated - aUpdated if (aUpdated !== bUpdated) return bUpdated - aUpdated
return cmp(a.id, b.id) return cmp(a.id, b.id)
} }
export function takeRecentSessions(sessions: SessionInfo[], limit: number, cutoff: number) { export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
if (limit <= 0) return [] as SessionInfo[] if (limit <= 0) return [] as Session[]
const selected: SessionInfo[] = [] const selected: Session[] = []
const seen = new Set<string>() const seen = new Set<string>()
for (const session of sessions) { for (const session of sessions) {
if (!session?.id) continue if (!session?.id) continue
@@ -32,7 +31,7 @@ export function takeRecentSessions(sessions: SessionInfo[], limit: number, cutof
} }
export function trimSessions( export function trimSessions(
input: SessionInfo[], input: Session[],
options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number }, options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
) { ) {
const limit = Math.max(0, options.limit) const limit = Math.max(0, options.limit)
@@ -5,17 +5,15 @@ import type {
Message, Message,
Part, Part,
Path, Path,
Todo,
VcsInfo,
} from "@/types"
import type {
FileDiffInfo,
PermissionRequest, PermissionRequest,
QuestionRequest, QuestionRequest,
ReferenceInfo, ReferenceInfo,
SessionInfo, Session,
SessionStatus, SessionStatus,
} from "@opencode-ai/client/promise" Todo,
VcsInfo,
} from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise" import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
@@ -44,7 +42,7 @@ export type State = {
provider: NormalizedProviderListResponse provider: NormalizedProviderListResponse
config: Config config: Config
path: Path path: Path
session: SessionInfo[] session: Session[]
sessionTotal: number sessionTotal: number
session_status: { session_status: {
[sessionID: string]: SessionStatus [sessionID: string]: SessionStatus
@@ -5,7 +5,7 @@ import type {
ModelListOutput, ModelListOutput,
ProviderListOutput, ProviderListOutput,
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils" import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
describe("normalizeAgentList", () => { describe("normalizeAgentList", () => {
test("adapts current agents to the app agent shape", () => { test("adapts current agents to the app agent shape", () => {
@@ -43,6 +43,30 @@ describe("normalizeAgentList", () => {
}) })
}) })
describe("normalizePermissionRequest", () => {
test("adapts the current permission request to app state", () => {
expect(
normalizePermissionRequest({
id: "permission-1",
sessionID: "session-1",
action: "read",
resources: ["README.md"],
save: ["*.md"],
metadata: { path: "README.md" },
source: { type: "tool", messageID: "message-1", id: "call-1" },
}),
).toEqual({
id: "permission-1",
sessionID: "session-1",
permission: "read",
patterns: ["README.md"],
always: ["*.md"],
metadata: { path: "README.md" },
tool: { messageID: "message-1", callID: "call-1" },
})
})
})
describe("normalizeProviderList", () => { describe("normalizeProviderList", () => {
test("groups current models into the app provider catalog", () => { test("groups current models into the app provider catalog", () => {
const result = normalizeProviderList( const result = normalizeProviderList(
+44 -2
View File
@@ -2,9 +2,10 @@ import type {
AgentListOutput, AgentListOutput,
ModelDefaultOutput, ModelDefaultOutput,
ModelListOutput, ModelListOutput,
PermissionRequest,
ProviderListOutput, ProviderListOutput,
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import type { Agent, Project, Provider, ProviderListResponse } from "@/types" import type { Agent, Event, Project, Provider, ProviderListResponse } from "@/types"
import type { Project as CurrentProject } from "@opencode-ai/client/promise" import type { Project as CurrentProject } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
@@ -35,13 +36,42 @@ export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Ag
})) }))
} }
type LegacyPermissionRequest = Extract<Event, { type: "permission.asked" }>["properties"]
export function normalizePermissionRequest(input: PermissionRequest | LegacyPermissionRequest): LegacyPermissionRequest {
if ("permission" in input) return input
return {
id: input.id,
sessionID: input.sessionID,
permission: input.action,
patterns: input.resources,
always: input.save ?? [],
metadata: input.metadata ?? {},
tool:
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.id } : undefined,
}
}
export function normalizeProviderList( export function normalizeProviderList(
providers: ProviderListOutput["data"] | ProviderListResponse, providers: ProviderListOutput["data"] | ProviderListResponse,
models?: ModelListOutput["data"], models?: ModelListOutput["data"],
defaultModel?: ModelDefaultOutput["data"], defaultModel?: ModelDefaultOutput["data"],
): NormalizedProviderListResponse { ): NormalizedProviderListResponse {
if (!Array.isArray(providers)) { if (!Array.isArray(providers)) {
return providers return {
...providers,
all: new Map(
providers.all.map((provider) => [
provider.id,
{
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"),
),
},
]),
),
}
} }
const all = new Map<string, Provider>() const all = new Map<string, Provider>()
@@ -123,6 +153,18 @@ export function normalizeProviderList(
} }
} }
export function sanitizeProject(project: Project) {
if (!project.icon?.url && !project.icon?.override) return project
return {
...project,
icon: {
...project.icon,
url: undefined,
override: undefined,
},
}
}
export function normalizeProjectInfo(project: Project | CurrentProject): Project { export function normalizeProjectInfo(project: Project | CurrentProject): Project {
return { return {
...project, ...project,
+42 -3
View File
@@ -340,7 +340,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
void removePersisted(target, platform) void removePersisted(target, platform)
if (scope !== ServerScope.local) continue if (scope !== ServerScope.local) continue
const legacyKey = `${dir}/${entry["legacy"]}${session ? "/" + session : ""}.${entry.version}` const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
void removePersisted({ key: legacyKey }, platform) void removePersisted({ key: legacyKey }, platform)
} }
} }
@@ -572,8 +572,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const projectID = project.id const projectID = project.id
void (async () => { void (async () => {
// TODO: Restore project color updates when the V2 client exposes a project update API. const sdk = serverSdk()
void projectID if ((await sdk.protocol) !== "v1") return
return sdk.legacy.project
.update({ projectID, directory: worktree, icon: { color } })
.then((response) => response.data)
.then((result) => {
if (!result) return
serverSync().set("project", (items) =>
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
)
})
})().catch(() => { })().catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
}) })
@@ -744,6 +753,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
mobileSidebar: { mobileSidebar: {
opened: createMemo(() => store.mobileSidebar?.opened ?? false), opened: createMemo(() => store.mobileSidebar?.opened ?? false),
show() {
setStore("mobileSidebar", "opened", true)
},
hide() { hide() {
setStore("mobileSidebar", "opened", false) setStore("mobileSidebar", "opened", false)
}, },
@@ -949,6 +961,33 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (current.reviewOpen.includes(path)) return if (current.reviewOpen.includes(path)) return
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path) setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
}, },
closePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current) return
const index = current.indexOf(path)
if (index === -1) return
setStore(
"sessionView",
session,
"reviewOpen",
produce((draft) => {
if (!draft) return
draft.splice(index, 1)
}),
)
},
togglePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current || !current.includes(path)) {
this.openPath(path)
return
}
this.closePath(path)
},
}, },
} }
}, },
+3 -3
View File
@@ -360,7 +360,7 @@ function createServerNotificationState(input: {
const handleSessionError = ( const handleSessionError = (
directory: string, directory: string,
event: { properties: EventSessionError["properties"] }, event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
time: number, time: number,
) => { ) => {
const sessionID = event.properties.sessionID const sessionID = event.properties.sessionID
@@ -372,7 +372,7 @@ function createServerNotificationState(input: {
void playSoundById(settings.sounds.errors()) void playSoundById(settings.sounds.errors())
} }
const error = event.properties.error const error = "error" in event.properties ? event.properties.error : undefined
append({ append({
directory, directory,
time, time,
@@ -393,7 +393,7 @@ function createServerNotificationState(input: {
const unsub = serverSDK().event.listen((e) => { const unsub = serverSDK().event.listen((e) => {
const event = e.details const event = e.details
if (event.type !== "session.idle" && event.type !== "session.execution.failed") return if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name const directory = e.name
const time = Date.now() const time = Date.now()
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise" import type { PermissionRequest, Session } from "@/types"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond" import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
@@ -7,7 +7,7 @@ const session = (input: { id: string; parentID?: string }) =>
({ ({
id: input.id, id: input.id,
parentID: input.parentID, parentID: input.parentID,
}) as SessionInfo }) as Session
const permission = (sessionID: string) => const permission = (sessionID: string) =>
({ ({
+8 -3
View File
@@ -1,7 +1,7 @@
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/client/promise" import type { PermissionRequest } from "@/types"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { ServerSDK } from "@/context/server-sdk" import type { ServerSDK } from "@/context/server-sdk"
import type { ServerSync } from "./server-sync" import type { ServerSync } from "./server-sync"
@@ -13,6 +13,7 @@ import { type DraftTab, useTabs } from "./tabs"
import { useSettings } from "./settings" import { useSettings } from "./settings"
import { requireServerKey } from "@/utils/session-route" import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { normalizePermissionRequest } from "./global-sync/utils"
import { import {
acceptKey, acceptKey,
directoryAcceptKey, directoryAcceptKey,
@@ -132,7 +133,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
if (draft) return draft.directory if (draft) return draft.directory
if (!params.id) return if (!params.id) return
if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return
return selected().sync.session.lineage.peek(params.id)?.session.location.directory return selected().sync.session.lineage.peek(params.id)?.session.directory
}) })
createEffect(() => { createEffect(() => {
@@ -211,6 +212,7 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
) )
function enableConfiguredDirectory(directory: string) { function enableConfiguredDirectory(directory: string) {
if (input.sdk.protocolKind() !== "v1") return
if (meta.disposed || !ready()) return if (meta.disposed || !ready()) return
const [childStore] = input.sync.child(directory) const [childStore] = input.sync.child(directory)
if (childStore.config.permission !== "allow") return if (childStore.config.permission !== "allow") return
@@ -248,6 +250,7 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
sessionID: request.sessionID, sessionID: request.sessionID,
requestID: request.permissionID, requestID: request.permissionID,
reply: request.response, reply: request.response,
location: request.directory ? { directory: request.directory } : undefined,
}) })
.catch(() => { .catch(() => {
responded.delete(request.permissionID) responded.delete(request.permissionID)
@@ -255,7 +258,9 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
} }
const list = async (directory: string) => { const list = async (directory: string) => {
return input.sdk.api.permission.request.list({ location: { directory } }).then((result) => result.data) return input.sdk.api.permission.request
.list({ location: { directory } })
.then((result) => result.data.map(normalizePermissionRequest))
} }
function respondOnce(permission: PermissionRequest, directory?: string) { function respondOnce(permission: PermissionRequest, directory?: string) {
+3 -5
View File
@@ -1,9 +1,10 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, createMemo } from "solid-js" import { type Accessor, createMemo } from "solid-js"
import { type DirectorySDK, useServerSDK } from "./server-sdk" import { type DirectorySDK, useServerSDK } from "./server-sdk"
export type { DirectorySDK } from "./server-sdk"
const context = createSimpleContext({ export type { DirectorySDK }
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK", name: "SDK",
// Resolves the directory-scoped SDK reactively from the (possibly changing) server. // Resolves the directory-scoped SDK reactively from the (possibly changing) server.
init: (props: { directory: string | Accessor<string> }) => { init: (props: { directory: string | Accessor<string> }) => {
@@ -14,6 +15,3 @@ const context = createSimpleContext({
}) })
}, },
}) })
export const useSDK: () => Accessor<DirectorySDK> = context.use
export const SDKProvider = context.provider
+152 -23
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@/types"
describe("resumeStreamAfterPageShow", () => { describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => { test("restarts a stream only after a back-forward cache restore", () => {
@@ -15,7 +16,7 @@ describe("resumeStreamAfterPageShow", () => {
}) })
describe("adaptServerEvent", () => { describe("adaptServerEvent", () => {
test("preserves current permission requests", () => { test("preserves V2 events while adapting permission requests for existing consumers", () => {
const current = { const current = {
id: "evt_1", id: "evt_1",
created: 1, created: 1,
@@ -30,38 +31,55 @@ describe("adaptServerEvent", () => {
} as OpenCodeEvent } as OpenCodeEvent
expect(adaptServerEvent(current)).toMatchObject({ expect(adaptServerEvent(current)).toMatchObject({
id: "evt_1",
type: "permission.asked", type: "permission.asked",
properties: { properties: {
id: "perm_1", id: "perm_1",
sessionID: "ses_1", sessionID: "ses_1",
action: "read", permission: "read",
resources: ["src/**"], patterns: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" }, tool: { messageID: "msg_1", callID: "call_1" },
}, },
current, current,
}) })
}) })
}) })
describe("current event buffering", () => { describe("coalesceServerEvents", () => {
const delta = (id: string, value: string, ordinal = 0) => ({ const delta = (value: string, field = "text", partID = "part") => ({
directory: "/repo", directory: "/repo",
payload: adaptServerEvent({ payload: {
type: "message.part.delta",
properties: { messageID: "msg", partID, field, delta: value },
} as Event,
})
test("merges adjacent deltas for the same field", () => {
const first = delta("hello ")
const second = delta("world")
first.payload.id = "first"
second.payload.id = "second"
const result = coalesceServerEvents([first, second])
expect(result).toHaveLength(1)
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
})
test("merges adjacent current text deltas", () => {
const current = (id: string, value: string) =>
adaptServerEvent({
id, id,
created: 1, created: 1,
type: "session.text.delta", type: "session.text.delta",
location: { directory: "/repo" }, location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value }, data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value },
} as OpenCodeEvent), } as OpenCodeEvent)
}) const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "hello ") },
test("merges adjacent text deltas for the same message and ordinal", () => { { directory: "/repo", payload: current("evt_2", "world") },
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")]) ])
expect(result).toHaveLength(1) expect(result).toHaveLength(1)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } }) expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
}) })
test("coalesces current tool input deltas by tool ID", () => { test("coalesces current tool input deltas by tool ID", () => {
@@ -84,16 +102,127 @@ describe("current event buffering", () => {
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } }) expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
}) })
test("preserves boundaries between distinct delta streams", () => { test("preserves event boundaries and distinct fields", () => {
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")] const status = {
directory: "/repo",
payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event,
}
const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")])
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"]) expect(result.map((event) => event.payload.type)).toEqual([
"message.part.delta",
"message.part.delta",
"session.status",
"message.part.delta",
])
}) })
test("preserves current event order when enqueuing", () => { test("preserves event ID order across interleaved deltas", () => {
const events: Parameters<typeof enqueueServerEvent>[0] = [] const first = delta("a")
;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event)) const other = delta("b", "text", "other")
const last = delta("c")
first.payload.id = "1"
other.payload.id = "2"
last.payload.id = "3"
expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"]) const result = coalesceServerEvents([first, other, last])
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)
}) })
}) })
+156 -17
View File
@@ -1,16 +1,24 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@/types" import type { Event, PermissionRequest } from "@/types"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js" import { type Accessor, batch, createMemo, createResource, onCleanup, onMount } from "solid-js"
import { createApiForServer, type ServerApi } from "@/utils/server" import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server"
import { useLanguage } from "./language" import { useLanguage } from "./language"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server" import { ServerConnection, useServer } from "./server"
import { createRefCountMap } from "@/utils/refcount" import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global" import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
import {
createCompatibleApi,
createLegacyCapabilities,
type CompatibleApi,
type LegacyCapabilities,
} from "@/utils/server-compat"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
const isAbortError = (error: unknown) => const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
@@ -24,10 +32,44 @@ type CurrentDelta = Extract<
> >
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent { export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
if (event.type === "permission.asked") {
return {
id: event.id,
type: "permission.asked",
properties: {
id: event.data.id,
sessionID: event.data.sessionID,
permission: event.data.action,
patterns: event.data.resources,
always: event.data.save ?? [],
metadata: event.data.metadata ?? {},
tool:
event.data.source?.type === "tool"
? { messageID: event.data.source.messageID, callID: event.data.source.id }
: undefined,
} satisfies PermissionRequest,
current: event,
}
}
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
} }
const coalescedKey = (event: QueuedServerEvent) => {
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 enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
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) queue.push(event)
return true return true
} }
@@ -63,7 +105,33 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
output.push(event) output.push(event)
return return
} }
if (event.payload.type !== "message.part.delta") {
output.push(event) output.push(event)
return
}
const props = event.payload.properties
const previous = output[output.length - 1]
if (
!previous ||
previous.payload.type !== "message.part.delta" ||
previous.directory !== event.directory ||
previous.payload.properties.messageID !== props.messageID ||
previous.payload.properties.partID !== props.partID ||
previous.payload.properties.field !== props.field
) {
output.push({
directory: event.directory,
payload: { ...event.payload, properties: { ...props } },
})
return
}
output[output.length - 1] = {
directory: event.directory,
payload: {
...event.payload,
properties: { ...props, delta: previous.payload.properties.delta + props.delta },
},
}
}) })
return output return output
} }
@@ -98,13 +166,21 @@ type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]:
type ServerSDKBase = { type ServerSDKBase = {
server: ServerConnection.Any server: ServerConnection.Any
scope: ServerScope scope: ServerScope
protocol: Promise<ServerProtocol>
protocolKind: Accessor<ServerProtocol | undefined>
url: string url: string
api: ServerApi client: ReturnType<typeof createSdkForServer>
api: CompatibleApi
legacy: LegacyCapabilities
currentApi: ServerApi
event: { event: {
on: ServerEventEmitter["on"] on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"] listen: ServerEventEmitter["listen"]
start: () => Promise<void> | undefined start: () => Promise<void> | undefined
} }
createClient: (
opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">,
) => ReturnType<typeof createSdkForServer>
} }
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase { function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
@@ -123,6 +199,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
})() })()
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch }) const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
const [protocolKind] = createResource(
() => protocol,
(value) => value,
)
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<{
[key: string]: ServerEvent [key: string]: ServerEvent
}>() }>()
@@ -240,23 +321,66 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
flush() flush()
}) })
const api = createApiForServer({ server: server.http, fetch: platform.fetch }) const sdk = createSdkForServer({
server: server.http,
fetch: platform.fetch,
throwOnError: true,
})
const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch })
const legacy = (directory?: string) =>
createSdkForServer({
server: server.http,
fetch: platform.fetch,
throwOnError: true,
directory,
})
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
const capabilities = createLegacyCapabilities({ protocol, current: currentApi, legacy })
return { return {
server, server,
scope, scope,
protocol,
protocolKind,
url: server.http.url, url: server.http.url,
client: sdk,
api, api,
legacy: capabilities,
currentApi,
event: { event: {
on: emitter.on.bind(emitter), on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter), listen: emitter.listen.bind(emitter),
start, start,
}, },
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
return createSdkForServer({
server: server.http,
fetch: platform.fetch,
...opts,
})
},
} }
} }
type SDKEventMap = {
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
}
export type DirectorySDK = {
scope: ServerScope
protocol: Promise<ServerProtocol>
directory: string
client: OpencodeClient
currentApi: ServerApi
api: CompatibleApi
legacy: LegacyCapabilities
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
readonly url: string
createClient: ServerSDKBase["createClient"]
}
export type ServerSDK = ServerSDKBase & { export type ServerSDK = ServerSDKBase & {
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext> ensureDirSdkContext: (directory: string) => DirectorySDK
} }
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK { export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
@@ -283,19 +407,17 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
}, },
}) })
type SDKEventMap = { export function useServerProtocol() {
[key in Event["type"]]: Extract<ServerEvent, { type: key }> const serverSDK = useServerSDK()
} return createMemo(() => serverSDK().protocolKind())
export type DirectorySDK = {
scope: ServerScope
directory: string
api: ServerApi
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
readonly url: string
} }
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK { function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
const client = serverSDK.createClient({
directory,
throwOnError: true,
})
const emitter = createGlobalEmitter<SDKEventMap>() const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.event.on(directory, (event) => { const unsub = serverSDK.event.on(directory, (event) => {
@@ -305,11 +427,28 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): Direc
return { return {
scope: serverSDK.scope, scope: serverSDK.scope,
protocol: serverSDK.protocol,
directory, directory,
api: serverSDK.api, client,
currentApi: serverSDK.currentApi,
api: createCompatibleApi({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory,
}),
legacy: createLegacyCapabilities({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory,
}),
event: emitter, event: emitter,
get url() { get url() {
return serverSDK.url return serverSDK.url
}, },
createClient(opts: Parameters<typeof serverSDK.createClient>[0]) {
return serverSDK.createClient(opts)
},
} }
} }
+128 -176
View File
@@ -1,26 +1,20 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { retry } from "@opencode-ai/core/util/retry" import type { retry } from "@opencode-ai/core/util/retry"
import type { import type { OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise"
OpenCodeEvent, import type { Message, Part, Session } from "@/types"
SessionApi, import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
SessionInfo,
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import type { Message, Part } from "@/types"
import { createServerSession } from "./server-session" import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
type MessageApi = ServerApi["message"] type MessageApi = ServerApi["message"]
const session = (id: string, parentID?: string): SessionInfo => ({ const session = (id: string, parentID?: string): Session => ({
id, id,
slug: id,
projectID: "project", projectID: "project",
location: { directory: "/repo" }, directory: "/repo",
title: id, title: id,
cost: 0, version: "1",
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
parentID, parentID,
time: { created: 1, updated: 1 }, time: { created: 1, updated: 1 },
}) })
@@ -28,120 +22,19 @@ const session = (id: string, parentID?: string): SessionInfo => ({
type UserMessage = Extract<Message, { role: "user" }> type UserMessage = Extract<Message, { role: "user" }>
type AssistantMessage = Extract<Message, { role: "assistant" }> type AssistantMessage = Extract<Message, { role: "assistant" }>
type TextPart = Extract<Part, { type: "text" }> type TextPart = Extract<Part, { type: "text" }>
type CurrentToolObject = Extract<SessionMessageAssistantTool["state"], { status: "running" }>["input"]
type MessageResponse = { type MessageResponse = {
data: { info: Message; parts: Part[] }[] data: { info: Message; parts: Part[] }[]
response: { headers: Headers } response: { headers: Headers }
} }
type SingleMessageResponse = { data: MessageResponse["data"][number] } type SingleMessageResponse = { data: MessageResponse["data"][number] }
function sessionInfo(value: SessionInfo): SessionInfo {
return value
}
function currentMessages(data: MessageResponse["data"]): SessionMessageInfo[] {
return data.flatMap((item): SessionMessageInfo[] => {
if (item.info.role === "user") {
return [
{
id: `${item.info.id}:agent`,
type: "agent-switched",
agent: item.info.agent,
time: item.info.time,
},
{
id: `${item.info.id}:model`,
type: "model-switched",
model: {
id: item.info.model.modelID,
providerID: item.info.model.providerID,
variant: item.info.model.variant,
},
time: item.info.time,
},
{
id: item.info.id,
type: "user",
text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
time: item.info.time,
},
]
}
return [{
id: item.info.id,
type: "assistant",
agent: item.info.agent,
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
content: item.parts.flatMap((part): SessionMessageAssistant["content"] => {
if (part.type === "text") return [{ type: "text", text: part.text }]
if (part.type === "reasoning")
return [
{
type: "reasoning",
text: part.text,
time: part.time ? { created: part.time.start, completed: part.time.end } : undefined,
},
]
if (part.type !== "tool") return []
const state: SessionMessageAssistantTool["state"] = (() => {
if (part.state.status === "pending") return { status: "streaming" as const, input: JSON.stringify(part.state.input) }
if (part.state.status === "running")
return {
status: "running" as const,
input: part.state.input as CurrentToolObject,
metadata: (part.state.metadata ?? {}) as CurrentToolObject,
}
if (part.state.status === "error")
return {
status: "error" as const,
input: part.state.input as CurrentToolObject,
error: { type: "tool_error", message: part.state.error },
metadata: part.state.metadata as CurrentToolObject | undefined,
}
return {
status: "completed" as const,
input: part.state.input as CurrentToolObject,
content: [{ type: "text" as const, text: part.state.output }],
metadata: part.state.metadata as CurrentToolObject,
}
})()
return [
{
id: part.id,
type: "tool" as const,
name: part.tool,
state,
time: {
created: part.state.status === "pending" ? item.info.time.created : part.state.time.start,
ran: part.state.status === "pending" ? undefined : part.state.time.start,
completed:
part.state.status === "completed" || part.state.status === "error" ? part.state.time.end : undefined,
},
},
]
}),
time: item.info.time,
cost: item.info.cost,
tokens: item.info.tokens,
finish: item.info.finish as "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | undefined,
}]
})
}
function currentPage(value: MessageResponse) {
return {
data: currentMessages(value.data).toReversed(),
cursor: { next: value.response.headers.get("x-next-cursor") ?? undefined },
}
}
const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage => ({ const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage => ({
id, id,
sessionID: "child", sessionID: "child",
role: "user", role: "user",
time: { created: 1 }, time: { created: 1 },
agent: "build", agent: "build",
model: { providerID: "provider", modelID: "model", variant: undefined }, model: { providerID: "provider", modelID: "model" },
...input, ...input,
}) })
@@ -153,24 +46,21 @@ const assistantMessage = (id: string, parentID: string, input: Partial<Assistant
parentID, parentID,
modelID: "model", modelID: "model",
providerID: "provider", providerID: "provider",
variant: undefined,
mode: "build", mode: "build",
agent: "build", agent: "build",
path: { cwd: "", root: "" }, path: { cwd: "/repo", root: "/repo" },
cost: 0, cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
error: undefined,
finish: undefined,
...input, ...input,
}) })
const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({ const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({
id: "part",
sessionID: "child", sessionID: "child",
messageID, messageID,
type: "text", type: "text",
text: "text", text: "text",
...input, ...input,
id: `${messageID}:text:${input.id === "pending" ? 1 : 0}`,
}) })
const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({ const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({
@@ -184,27 +74,19 @@ const deferredResponse = () => Promise.withResolvers<MessageResponse>()
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) { function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
let index = 0 let index = 0
const pages = responses.map((value) =>
value instanceof Promise ? value.then(currentPage) : Promise.resolve(currentPage(value)),
)
const requests: unknown[] = [] const requests: unknown[] = []
const waiting = new Map<number, () => void>() const waiting = new Map<number, () => void>()
const client = { const client = {
session: { session: {
get: async () => sessionInfo(session("child", "root")), get: async () => ({ data: session("child", "root") }),
message: async () => { messages: (input: unknown) => {
throw new Error("Unexpected single message request")
},
},
message: {
list: async (input: unknown) => {
requests.push(input) requests.push(input)
waiting.get(requests.length)?.() waiting.get(requests.length)?.()
waiting.delete(requests.length) waiting.delete(requests.length)
return pages[index++]! return responses[index++]
}, },
}, },
} as unknown as { session: SessionApi; message: MessageApi } } as unknown as OpencodeClient
return Object.assign(client, { return Object.assign(client, {
requests, requests,
requested(count: number) { requested(count: number) {
@@ -225,22 +107,19 @@ function rootMessageClient(
const rootWaiting = new Map<number, () => void>() const rootWaiting = new Map<number, () => void>()
const client = { const client = {
session: { session: {
get: async () => sessionInfo(session("child", "root")), get: async () => ({ data: session("child", "root") }),
message: async (input: unknown) => { messages: (input: unknown) => {
requests.push(input)
return pages[pageIndex++]
},
message: (input: unknown) => {
rootRequests.push(input) rootRequests.push(input)
rootWaiting.get(rootRequests.length)?.() rootWaiting.get(rootRequests.length)?.()
rootWaiting.delete(rootRequests.length) rootWaiting.delete(rootRequests.length)
const value = await roots[rootIndex++]! return roots[rootIndex++]
return currentMessages([value.data]).find((message) => message.id === value.data.info.id)!
}, },
}, },
message: { } as unknown as OpencodeClient
list: async (input: unknown) => {
requests.push(input)
return currentPage(await pages[pageIndex++]!)
},
},
} as unknown as { session: SessionApi; message: MessageApi }
return Object.assign(client, { return Object.assign(client, {
requests, requests,
rootRequests, rootRequests,
@@ -262,7 +141,7 @@ const retryImmediately: typeof retry = async (task, options = {}) => {
} }
} }
function setup(sessions: Record<string, SessionInfo>) { function setup(sessions: Record<string, Session>) {
const get: unknown[] = [] const get: unknown[] = []
const messages: unknown[] = [] const messages: unknown[] = []
const client = { const client = {
@@ -270,19 +149,16 @@ function setup(sessions: Record<string, SessionInfo>) {
get: async (input: unknown) => { get: async (input: unknown) => {
get.push(input) get.push(input)
const id = (input as { sessionID: string }).sessionID const id = (input as { sessionID: string }).sessionID
return sessionInfo(sessions[id]!) return { data: sessions[id] }
}, },
message: async () => { messages: async (input: unknown) => {
throw new Error("Unexpected single message request")
},
},
message: {
list: async (input: unknown) => {
messages.push(input) messages.push(input)
return currentPage(response()) return response()
}, },
diff: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
}, },
} as unknown as { session: SessionApi; message: MessageApi } } as unknown as OpencodeClient
return { get, messages, store: createServerSession(client) } return { get, messages, store: createServerSession(client) }
} }
@@ -354,7 +230,7 @@ describe("server session", () => {
await ctx.store.sync("root") await ctx.store.sync("root")
expect(ctx.get).toEqual([{ sessionID: "root" }]) expect(ctx.get).toEqual([{ sessionID: "root" }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, order: "desc" }]) expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }])
expect(ctx.store.data.message.root).toEqual([]) expect(ctx.store.data.message.root).toEqual([])
}) })
@@ -369,19 +245,54 @@ describe("server session", () => {
content: [{ type: "text", text: "hi" }], content: [{ type: "text", text: "hi" }],
time: { created: 2, completed: 3 }, time: { created: 2, completed: 3 },
} }
const client = {
session: {
messages: () => {
throw new Error("legacy message endpoint called")
},
},
} as unknown as OpencodeClient
const messageApi = { const messageApi = {
list: async (input: unknown) => { list: async (input: unknown) => {
requests.push(input) requests.push(input)
return { data: [assistant, user], cursor: { previous: null, next: null } } return { data: [assistant, user], cursor: { previous: null, next: null } }
}, },
} as unknown as MessageApi } as unknown as MessageApi
const store = createServerSession({} as SessionApi, messageApi) const store = createServerSession(client, {} as SessionApi, messageApi)
store.remember(session("root")) store.remember(session("root"))
await store.sync("root") await store.sync("root")
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }]) expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.history.more("root")).toBe(false)
})
test("replaces stale current projections on complete refreshes", async () => {
const first = { id: "msg_1", type: "user", text: "first", time: { created: 1 } } as const
const second = { id: "msg_2", type: "user", text: "second", time: { created: 2 } } as const
const pages = [
{ data: [first], cursor: { previous: null, next: null } },
{ data: [second], cursor: { previous: null, next: null } },
{ data: [], cursor: { previous: null, next: null } },
]
const messageApi = {
list: async () => pages.shift()!,
} as unknown as MessageApi
const sessionApi = { get: async () => session("root") } as unknown as SessionApi
const store = createServerSession({} as OpencodeClient, sessionApi, messageApi)
store.remember(session("root"))
await store.sync("root")
expect(store.data.session_message.root.map((message) => message.id)).toEqual([first.id])
await store.sync("root", { force: true })
expect(store.data.session_message.root.map((message) => message.id)).toEqual([second.id])
expect(store.data.message.root.map((message) => message.id)).toEqual([second.id])
await store.sync("root", { force: true })
expect(store.data.session_message.root).toEqual([])
expect(store.data.message.root).toEqual([])
}) })
test("extends a current page to include the user for split assistant turns", async () => { test("extends a current page to include the user for split assistant turns", async () => {
@@ -410,7 +321,7 @@ describe("server session", () => {
return pages.shift()! return pages.shift()!
}, },
} as unknown as MessageApi } as unknown as MessageApi
const store = createServerSession({} as SessionApi, messageApi) const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi)
store.remember(session("root")) store.remember(session("root"))
await store.sync("root") await store.sync("root")
@@ -426,14 +337,50 @@ describe("server session", () => {
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"]) expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
}) })
// V2 messages are ordered projections and do not expose V1 assistant parent IDs. test("indexes V1 messages for the current timeline projection", async () => {
describe.skip("V1 assistant parent projections", () => { const user = userMessage("message-1", { sessionID: "root" })
const assistant = assistantMessage("message-2", user.id, { sessionID: "root" })
const client = messageClient(
response([
{ info: user, parts: [textPart(user.id, { sessionID: "root" })] },
{ info: assistant, parts: [textPart(assistant.id, { sessionID: "root" })] },
]),
)
const messageApi = {
list: () => {
throw new Error("current message endpoint called")
},
} as unknown as MessageApi
const store = createServerSession(client, {} as SessionApi, messageApi, {
protocol: Promise.resolve("v1"),
})
store.remember(session("root"))
await store.sync("root")
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.data.session_message.root).toMatchObject([
{ id: user.id, type: "user", text: "text" },
{ id: assistant.id, type: "assistant" },
])
const next = userMessage("message-3", { sessionID: "root" })
store.apply({ type: "message.updated", properties: { info: next } })
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id, next.id])
store.apply({ type: "message.removed", properties: { sessionID: "root", messageID: next.id } })
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
})
test("backfills an assistant-only initial page through its user root", async () => { test("backfills an assistant-only initial page through its user root", async () => {
const user = userMessage("message-1") const user = userMessage("message-1")
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient( const client = rootMessageClient(
[ [
response(assistants.map((info) => ({ info, parts: [] }))), response(
assistants.map((info) => ({ info, parts: [] })),
"older",
),
], ],
[singleResponse(user)], [singleResponse(user)],
) )
@@ -441,16 +388,16 @@ describe("server session", () => {
await store.sync("child") await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }]) expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }]) expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants]) expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(false) expect(store.history.more("child")).toBe(true)
}) })
test("keeps assistant history when its deleted parent cannot be backfilled", async () => { test("keeps assistant history when its deleted parent cannot be backfilled", async () => {
const missing = Promise.withResolvers<SingleMessageResponse>() const missing = Promise.withResolvers<SingleMessageResponse>()
const assistant = assistantMessage("message-2", "message-missing") const assistant = assistantMessage("message-2", "message-missing")
const client = rootMessageClient([response([{ info: assistant, parts: [] }])], [missing.promise]) const client = rootMessageClient([response([{ info: assistant, parts: [] }], "older")], [missing.promise])
const store = createServerSession(client) const store = createServerSession(client)
const loading = store.sync("child") const loading = store.sync("child")
await client.rootRequested(1) await client.rootRequested(1)
@@ -460,7 +407,7 @@ describe("server session", () => {
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }]) expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }])
expect(store.data.message.child).toEqual([assistant]) expect(store.data.message.child).toEqual([assistant])
expect(store.history.more("child")).toBe(false) expect(store.history.more("child")).toBe(true)
}) })
test("drops a cached parent when a forced refresh confirms it was deleted", async () => { test("drops a cached parent when a forced refresh confirms it was deleted", async () => {
@@ -474,7 +421,7 @@ describe("server session", () => {
{ info: parent, parts: [part] }, { info: parent, parts: [part] },
{ info: assistant, parts: [] }, { info: assistant, parts: [] },
]), ]),
response([{ info: assistant, parts: [] }]), response([{ info: assistant, parts: [] }], "older"),
], ],
[missing.promise], [missing.promise],
) )
@@ -496,7 +443,10 @@ describe("server session", () => {
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient( const client = rootMessageClient(
[ [
response(assistants.map((info) => ({ info, parts: [] }))), response(
assistants.map((info) => ({ info, parts: [] })),
"older",
),
], ],
[singleResponse(user)], [singleResponse(user)],
) )
@@ -518,7 +468,10 @@ describe("server session", () => {
const client = rootMessageClient( const client = rootMessageClient(
[ [
response([{ info: unrelated, parts: [] }]), response([{ info: unrelated, parts: [] }]),
response(assistants.map((info) => ({ info, parts: [] }))), response(
assistants.map((info) => ({ info, parts: [] })),
"older",
),
], ],
[singleResponse(user)], [singleResponse(user)],
) )
@@ -537,7 +490,7 @@ describe("server session", () => {
const cached = userMessage("message-3", { time: { created: 3 } }) const cached = userMessage("message-3", { time: { created: 3 } })
const assistant = assistantMessage("message-4", user.id) const assistant = assistantMessage("message-4", user.id)
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }])], [response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }], "older")],
[singleResponse(user)], [singleResponse(user)],
) )
const store = createServerSession(client) const store = createServerSession(client)
@@ -555,7 +508,7 @@ describe("server session", () => {
const freshPart = { ...stalePart, text: "fresh" } const freshPart = { ...stalePart, text: "fresh" }
const assistant = assistantMessage("message-2", stale.id) const assistant = assistantMessage("message-2", stale.id)
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }])], [response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }], "older")],
[singleResponse(fresh, [freshPart])], [singleResponse(fresh, [freshPart])],
) )
const store = createServerSession(client) const store = createServerSession(client)
@@ -576,7 +529,7 @@ describe("server session", () => {
const pending = textPart(stale.id, { id: "pending", text: "pending" }) const pending = textPart(stale.id, { id: "pending", text: "pending" })
const assistant = assistantMessage("message-2", stale.id) const assistant = assistantMessage("message-2", stale.id)
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }])], [response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }], "older")],
[singleResponse(fresh, [refreshed])], [singleResponse(fresh, [refreshed])],
) )
const store = createServerSession(client) const store = createServerSession(client)
@@ -599,7 +552,7 @@ describe("server session", () => {
const loading = store.sync("child") const loading = store.sync("child")
store.apply({ type: "message.updated", properties: { info: user } }) store.apply({ type: "message.updated", properties: { info: user } })
pending.resolve(response([{ info: assistant, parts: [] }])) pending.resolve(response([{ info: assistant, parts: [] }], "older"))
await loading await loading
expect(client.rootRequests).toEqual([]) expect(client.rootRequests).toEqual([])
@@ -615,6 +568,7 @@ describe("server session", () => {
[ [
response( response(
assistants.map((info) => ({ info, parts: [] })), assistants.map((info) => ({ info, parts: [] })),
"older",
), ),
], ],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
@@ -638,7 +592,7 @@ describe("server session", () => {
const assistant = assistantMessage("message-2", user.id) const assistant = assistantMessage("message-2", user.id)
const live = { ...assistant, cost: 1 } const live = { ...assistant, cost: 1 }
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: assistant, parts: [] }])], [response([{ info: assistant, parts: [] }], "older")],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
) )
const store = createServerSession(client, { retry: retryImmediately }) const store = createServerSession(client, { retry: retryImmediately })
@@ -658,7 +612,7 @@ describe("server session", () => {
const assistant = assistantMessage("message-2", user.id) const assistant = assistantMessage("message-2", user.id)
const live = userMessage("message-4", { time: { created: 4 } }) const live = userMessage("message-4", { time: { created: 4 } })
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: assistant, parts: [] }])], [response([{ info: assistant, parts: [] }], "older")],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
) )
const store = createServerSession(client, { retry: retryImmediately }) const store = createServerSession(client, { retry: retryImmediately })
@@ -679,7 +633,7 @@ describe("server session", () => {
const stale = textPart(assistant.id, { text: "stale" }) const stale = textPart(assistant.id, { text: "stale" })
const live = { ...stale, text: "live" } const live = { ...stale, text: "live" }
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: assistant, parts: [stale] }])], [response([{ info: assistant, parts: [stale] }], "older")],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
) )
const store = createServerSession(client, { retry: retryImmediately }) const store = createServerSession(client, { retry: retryImmediately })
@@ -692,7 +646,6 @@ describe("server session", () => {
expect(store.data.part[assistant.id]).toEqual([live]) expect(store.data.part[assistant.id]).toEqual([live])
}) })
})
test("merges live events into the initial page", async () => { test("merges live events into the initial page", async () => {
const pending = deferredResponse() const pending = deferredResponse()
@@ -1492,7 +1445,6 @@ describe("server session", () => {
await store.history.loadMore("child") await store.history.loadMore("child")
guard.active = false
expect(store.data.message.child).toEqual([older, latest]) expect(store.data.message.child).toEqual([older, latest])
}) })
@@ -1677,7 +1629,7 @@ describe("server session", () => {
ctx.store.apply({ type: "session.created", properties: { sessionID: "root", info: session("root") } }) ctx.store.apply({ type: "session.created", properties: { sessionID: "root", info: session("root") } })
ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } }) ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } })
expect(ctx.store.get("root")?.location.directory).toBe("/repo") expect(ctx.store.get("root")?.directory).toBe("/repo")
expect(ctx.store.data.session_working("root")).toBe(true) expect(ctx.store.data.session_working("root")).toBe(true)
expect(ctx.get).toEqual([]) expect(ctx.get).toEqual([])
}) })
+104 -48
View File
@@ -1,15 +1,23 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/core/util/retry"
import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise" import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { import type {
Message, Message,
Part, Part,
PermissionRequest,
QuestionRequest,
Session,
SessionStatus,
Todo, Todo,
} from "@/types" } from "@/types"
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise" import type { LegacyCapabilities } from "@/utils/server-compat"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { batch } from "solid-js" import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { message as cleanMessage } from "@/utils/diffs"
import { sessionNotFoundError } from "@/utils/server-errors"
import { rootSession } from "@/utils/session-route" import { rootSession } from "@/utils/session-route"
import { normalizeSessionInfo } from "@/utils/session"
import { normalizeSessionMessages } from "@/utils/session-message" import { normalizeSessionMessages } from "@/utils/session-message"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer" import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
@@ -25,29 +33,6 @@ const historyMessagePageSize = 200
const sessionInfoLimit = 2_048 const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set() const emptyIDs: ReadonlySet<string> = new Set()
function projectMessageSource(message: Message): SessionMessageInfo[] {
if (message.role === "user") {
return [
{ id: `${message.id}:agent`, type: "agent-switched", agent: message.agent, time: message.time },
{
id: `${message.id}:model`,
type: "model-switched",
model: { id: message.model.modelID, providerID: message.model.providerID, variant: message.model.variant },
time: message.time,
},
{ id: message.id, type: "user", text: "", time: message.time },
]
}
return [{
id: message.id,
type: "assistant",
agent: message.agent ?? message.mode,
model: { id: message.modelID, providerID: message.providerID, variant: message.variant },
content: [],
time: message.time,
}]
}
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) { function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
const boundary = source.find( const boundary = source.find(
(message) => (message) =>
@@ -76,6 +61,30 @@ type MessagePage = {
complete: boolean complete: boolean
} }
function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] {
return items
.slice()
.sort((a, b) => cmp(a.info.id, b.info.id))
.map((item) => {
if (item.info.role === "user") {
return {
id: item.info.id,
type: "user" as const,
text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
time: item.info.time,
}
}
return {
id: item.info.id,
type: "assistant" as const,
agent: item.info.agent ?? item.info.mode,
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
content: [],
time: item.info.time,
}
})
}
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries. // Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
type MessageLoadState = { type MessageLoadState = {
touchedMessages: Set<string> touchedMessages: Set<string>
@@ -174,20 +183,22 @@ function reconcileFetched<T extends { id: string }>(
return [...result.values()].sort((a, b) => cmp(a.id, b.id)) return [...result.values()].sort((a, b) => cmp(a.id, b.id))
} }
type ServerSessionOptions = { retry?: typeof retry } type ServerSessionOptions = {
type ServerSessionApis = { session: SessionApi; message: MessageApi } retry?: typeof retry
protocol?: Promise<"v1" | "v2">
legacy?: LegacyCapabilities
}
export function createServerSession( export function createServerSession(
api: SessionApi | ServerSessionApis, client: { session: Pick<LegacyCapabilities["session"], "get" | "messages" | "message"> },
messageApiOrOptions?: MessageApi | ServerSessionOptions, sessionApiOrOptions?: SessionApi | ServerSessionOptions,
messageApi?: MessageApi,
currentOptions?: ServerSessionOptions, currentOptions?: ServerSessionOptions,
) { ) {
const bundled = "session" in api const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined
const sessionApi = bundled ? api.session : api const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined)
const messageApi = bundled ? api.message : (messageApiOrOptions as MessageApi)
const options = bundled ? (messageApiOrOptions as ServerSessionOptions | undefined) : currentOptions
const [data, setData] = createStore({ const [data, setData] = createStore({
info: {} as Record<string, SessionInfo | undefined>, info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>, session_status: {} as Record<string, SessionStatus>,
session_diff: {} as Record<string, FileDiffInfo[]>, session_diff: {} as Record<string, FileDiffInfo[]>,
todo: {} as Record<string, Todo[]>, todo: {} as Record<string, Todo[]>,
@@ -201,7 +212,7 @@ export function createServerSession(
return (this.session_status[id]?.type ?? "idle") !== "idle" return (this.session_status[id]?.type ?? "idle") !== "idle"
}, },
}) })
const requests = new Map<string, Promise<SessionInfo>>() const requests = new Map<string, Promise<Session>>()
const inflight = new Map<string, Promise<void>>() const inflight = 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>>()
@@ -240,17 +251,17 @@ export function createServerSession(
at: {} as Record<string, number | undefined>, at: {} as Record<string, number | undefined>,
}) })
const indexProjectedMessage = (message: Message) => { const indexLegacyMessage = (message: Message) => {
const current = data.session_message[message.sessionID] ?? [] const current = data.session_message[message.sessionID] ?? []
if (current.some((item) => item.id === message.id)) return if (current.some((item) => item.id === message.id)) return
setData( setData(
"session_message", "session_message",
message.sessionID, message.sessionID,
reconcile([...current, ...projectMessageSource(message)]), reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]),
) )
} }
const remember = (session: SessionInfo) => { const remember = (session: Session) => {
setData("info", session.id, reconcile(session)) setData("info", session.id, reconcile(session))
infoSeen.delete(session.id) infoSeen.delete(session.id)
infoSeen.add(session.id) infoSeen.add(session.id)
@@ -300,7 +311,12 @@ export function createServerSession(
const pending = requests.get(sessionID) const pending = requests.get(sessionID)
if (pending) return pending if (pending) return pending
const active = generation(sessionID) const active = generation(sessionID)
const request = sessionApi.get({ sessionID }) const request = sessionApi
? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
: client.session.get({ sessionID }).then((result) => {
if (!result.data) throw sessionNotFoundError(sessionID)
return result.data
})
const resolved = request.then((result) => { const resolved = request.then((result) => {
if (generations.get(sessionID) !== active) return result if (generations.get(sessionID) !== active) return result
return remember(result) return remember(result)
@@ -526,6 +542,7 @@ export function createServerSession(
) )
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
if (messageApi && (await options?.protocol) !== "v1") {
const request = (cursor?: string) => const request = (cursor?: string) =>
(options?.retry ?? retry)(() => { (options?.retry ?? retry)(() => {
onAttempt?.() onAttempt?.()
@@ -553,8 +570,26 @@ export function createServerSession(
complete: !response.cursor.next, complete: !response.cursor.next,
} }
} }
const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
return client.session.messages({ sessionID, limit, before })
})
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
return {
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
part: items.map((item) => ({
id: item.info.id,
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
})),
source: legacyMessageSource(items),
sourceMode: before ? ("older" as const) : ("latest" as const),
cursor: response.response.headers.get("x-next-cursor") ?? undefined,
complete: !response.response.headers.get("x-next-cursor"),
}
}
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => { const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
if (sessionApi && (await options?.protocol) !== "v1") {
const response = await (options?.retry ?? retry)(() => { const response = await (options?.retry ?? retry)(() => {
onAttempt?.() onAttempt?.()
return sessionApi.message({ sessionID, messageID }) return sessionApi.message({ sessionID, messageID })
@@ -564,6 +599,16 @@ export function createServerSession(
if (!message) throw new Error(`Message not found: ${messageID}`) if (!message) throw new Error(`Message not found: ${messageID}`)
return { message, parts: normalized.parts.get(messageID) ?? [] } return { message, parts: normalized.parts.get(messageID) ?? [] }
} }
const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
return client.session.message({ sessionID, messageID })
})
if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`)
return {
message: cleanMessage(response.data.info),
parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
}
}
const replaceMessages = (sessionID: string, messages: Message[]) => { const replaceMessages = (sessionID: string, messages: Message[]) => {
const messageIDs = new Set(messages.map((message) => message.id)) const messageIDs = new Set(messages.map((message) => message.id))
@@ -916,8 +961,9 @@ export function createServerSession(
remember({ remember({
...info, ...info,
projectID: event.data.projectID ?? info.projectID, projectID: event.data.projectID ?? info.projectID,
location: event.data.location, workspaceID: event.data.location.workspaceID,
subpath: event.data.subpath, directory: event.data.location.directory,
path: event.data.subpath,
time: { ...info.time, updated: event.created }, time: { ...info.time, updated: event.created },
}) })
if (event.type === "session.usage.updated" && info) if (event.type === "session.usage.updated" && info)
@@ -963,17 +1009,16 @@ export function createServerSession(
} }
switch (event.type) { switch (event.type) {
case "session.created": case "session.created":
if ((event.properties as { info?: SessionInfo }).info) remember((event.properties as { info: Session }).info)
remember((event.properties as { info: SessionInfo }).info)
return return
case "session.updated": { case "session.updated": {
const info = (event.properties as { info: SessionInfo }).info const info = (event.properties as { info: Session }).info
remember(info) remember(info)
if (info.time.archived) evict([info.id]) if (info.time.archived) evict([info.id])
return return
} }
case "session.deleted": { case "session.deleted": {
const properties = event.properties as { sessionID?: string; info?: SessionInfo } const properties = event.properties as { sessionID?: string; info?: Session }
const sessionID = properties.info?.id ?? properties.sessionID const sessionID = properties.info?.id ?? properties.sessionID
if (!sessionID) return if (!sessionID) return
infoSeen.delete(sessionID) infoSeen.delete(sessionID)
@@ -995,8 +1040,8 @@ export function createServerSession(
return return
} }
case "message.updated": { case "message.updated": {
const info = (event.properties as { info: Message }).info const info = cleanMessage((event.properties as { info: Message }).info)
indexProjectedMessage(info) indexLegacyMessage(info)
const load = messageLoads.get(info.sessionID) const load = messageLoads.get(info.sessionID)
load?.touchedMessages.add(info.id) load?.touchedMessages.add(info.id)
load?.removedMessages.delete(info.id) load?.removedMessages.delete(info.id)
@@ -1347,8 +1392,19 @@ export function createServerSession(
async todo(sessionID: string, request?: { force?: boolean }) { async todo(sessionID: string, request?: { force?: boolean }) {
touch(sessionID) touch(sessionID)
if (data.todo[sessionID] !== undefined && !request?.force) return if (data.todo[sessionID] !== undefined && !request?.force) return
// TODO: Restore todos when the V2 client exposes a session todo API. if ((await options?.protocol) === "v2") {
// TODO: Restore todos when the V2 API exposes a session todo snapshot.
setData("todo", sessionID, []) setData("todo", sessionID, [])
return
}
return runInflight(inflightTodo, sessionID, () => {
const active = generation(sessionID)
if (!options?.legacy) return Promise.resolve()
return (options.retry ?? retry)(() => options.legacy!.session.todo(sessionID)).then((result) => {
if (generations.get(sessionID) !== active) return
setData("todo", sessionID, reconcile(result, { key: "id" }))
})
})
}, },
history: { history: {
more: (sessionID: string) => more: (sessionID: string) =>
+6 -3
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { import type {
McpListInput, McpListInput,
McpResourceCatalogInput, McpResourceCatalogInput,
@@ -83,7 +84,7 @@ describe("active session query", () => {
}) })
test("does not overwrite statuses already written by events", () => { test("does not overwrite statuses already written by events", () => {
const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"]) const session = createServerSession({} as OpencodeClient)
session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 }) session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 })
seedActiveSessionStatuses(session, { seedActiveSessionStatuses(session, {
@@ -123,7 +124,7 @@ describe("pickDirectoriesToEvict", () => {
}) })
describe("loadRootSessions", () => { describe("loadRootSessions", () => {
test("loads a limited page of root sessions", async () => { test("loads and normalizes a limited page of root sessions", async () => {
const calls: SessionListInput[] = [] const calls: SessionListInput[] = []
const result = await loadRootSessions({ const result = await loadRootSessions({
@@ -137,7 +138,9 @@ describe("loadRootSessions", () => {
limit: 10, limit: 10,
}) })
expect(result.data).toEqual([expect.objectContaining({ id: "session-1", location: { directory: "dir" } })]) expect(result.data).toEqual([
expect.objectContaining({ id: "session-1", directory: "dir", slug: "session-1", version: "" }),
])
expect(result.limited).toBe(true) expect(result.limited).toBe(true)
expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }]) expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }])
}) })

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