mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-08 18:30:00 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80697b2e9b | |||
| 0ea8f6d2fe | |||
| e027ce316b | |||
| 3e7efffcb6 |
@@ -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=="],
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ Route defaults are request-shaping defaults such as `headers`, `limits`, `genera
|
|||||||
|
|
||||||
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
|
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
|
||||||
|
|
||||||
When a provider ships a non-HTTP transport (OpenAI's WebSocket Responses backend, hypothetical bidirectional streaming APIs), the seam is `Transport` — `WebSocketTransport.jsonTransport.with(...)` constructs an IO template whose `prepare` receives the route endpoint/auth at compile time, builds a WebSocket URL and message, and whose `frames` yields decoded text from the socket. Same protocol and endpoint source, different transport.
|
When a provider supports multiple physical transports, selection remains execution policy below its semantic route. OpenAI Responses uses a purpose-built hybrid transport that prepares one final request, executes HTTP by default, and passes a generic channel exchange to a per-call `WebSocketChannelExecutor` when supplied. `Route.streamPrepared` owns decoding and acknowledges channel completion only after successful full consumption.
|
||||||
|
|
||||||
### URL Construction
|
### URL Construction
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ const proxied = gateway.model("openai/gpt-4o-mini")
|
|||||||
Keep provider facades small and explicit:
|
Keep provider facades small and explicit:
|
||||||
|
|
||||||
- Use branded `ProviderID.make(...)` and `ModelID.make(...)` where ids are constructed directly.
|
- Use branded `ProviderID.make(...)` and `ModelID.make(...)` where ids are constructed directly.
|
||||||
- Use `model` for the default API path and named methods for provider-native alternatives such as OpenAI `responses`, `responsesWebSocket`, and `chat`.
|
- Use `model` for the default API path and named methods for provider-native alternatives such as OpenAI `responses` and `chat`.
|
||||||
- Put provider-specific setup on `.configure(...)`; do not add `model(id, overrides)` as a duplicate construction path.
|
- Put provider-specific setup on `.configure(...)`; do not add `model(id, overrides)` as a duplicate construction path.
|
||||||
- Export lower-level `routes` arrays separately only when advanced internal wiring needs them.
|
- Export lower-level `routes` arrays separately only when advanced internal wiring needs them.
|
||||||
- Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`.
|
- Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`.
|
||||||
@@ -124,11 +124,10 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
|
|||||||
|
|
||||||
const selected = model("gpt-5", {
|
const selected = model("gpt-5", {
|
||||||
apiKey,
|
apiKey,
|
||||||
transport: "websocket",
|
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Keep transport choices inside the semantic entrypoint settings, so OpenAI Responses HTTP and WebSocket share one entrypoint. Provider facades may still expose named selectors such as `responsesWebSocket` for direct typed call sites; the package-like contract maps its settings to those selectors before returning an executable `LanguageModel`.
|
Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Transport is execution policy: OpenAI Responses uses HTTP by default and may receive a per-call WebSocket channel executor through `StreamOptions` without changing model or route identity.
|
||||||
|
|
||||||
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
|
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
|
||||||
|
|
||||||
@@ -154,9 +153,10 @@ packages/ai/src/
|
|||||||
auth-options.ts ProviderAuthOption shape, AuthOptions.bearer, AtLeastOne helper
|
auth-options.ts ProviderAuthOption shape, AuthOptions.bearer, AtLeastOne helper
|
||||||
framing.ts Framing type + Framing.sse
|
framing.ts Framing type + Framing.sse
|
||||||
transport/ transport implementations
|
transport/ transport implementations
|
||||||
index.ts Transport type + HttpTransport / WebSocketTransport namespaces
|
index.ts Transport execution types + HttpTransport / WebSocketTransport namespaces
|
||||||
|
websocket-channel.ts generic sequential channel executor/driver contract
|
||||||
http.ts HttpTransport.httpJson — POST + framing
|
http.ts HttpTransport.httpJson — POST + framing
|
||||||
websocket.ts WebSocketTransport.json + WebSocketExecutor service
|
websocket.ts direct one-request channel executor + raw socket adapter
|
||||||
protocols/
|
protocols/
|
||||||
shared.ts ProviderShared toolkit used inside protocol impls
|
shared.ts ProviderShared toolkit used inside protocol impls
|
||||||
openai-chat.ts protocol + route (compose OpenAIChat.protocol)
|
openai-chat.ts protocol + route (compose OpenAIChat.protocol)
|
||||||
|
|||||||
@@ -315,7 +315,6 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
|
|||||||
|
|
||||||
const selected = model("gpt-5", {
|
const selected = model("gpt-5", {
|
||||||
apiKey: process.env.OPENAI_API_KEY,
|
apiKey: process.env.OPENAI_API_KEY,
|
||||||
transport: "websocket",
|
|
||||||
headers: { "x-application": "opencode" },
|
headers: { "x-application": "opencode" },
|
||||||
limits: { context: 200_000, output: 64_000 },
|
limits: { context: 200_000, output: 64_000 },
|
||||||
})
|
})
|
||||||
@@ -332,7 +331,7 @@ OpenAI Chat and OpenAI Responses are separate semantic entrypoints:
|
|||||||
- `@opencode-ai/ai/providers/google-vertex/responses`
|
- `@opencode-ai/ai/providers/google-vertex/responses`
|
||||||
- `@opencode-ai/ai/providers/google-vertex/messages`
|
- `@opencode-ai/ai/providers/google-vertex/messages`
|
||||||
|
|
||||||
Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; the Responses adapter at `providers/openai-compatible/responses` uses the provider-neutral Open Responses protocol. OpenAI Responses extends that baseline with OpenAI tools, event variants, metadata, defaults, and transports. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths.
|
OpenAI Responses has one semantic route and uses HTTP by default. Advanced callers may supply a per-call WebSocket channel executor through `StreamOptions`; transport policy does not change provider settings, model identity, or route identity. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; the Responses adapter at `providers/openai-compatible/responses` uses the provider-neutral Open Responses protocol. OpenAI Responses extends that baseline with OpenAI tools, event variants, metadata, and defaults. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths.
|
||||||
|
|
||||||
Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`.
|
Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`.
|
||||||
|
|
||||||
|
|||||||
+15
-16
@@ -1,6 +1,6 @@
|
|||||||
# LLM Provider Parity Status
|
# LLM Provider Parity Status
|
||||||
|
|
||||||
Last reviewed: 2026-07-24
|
Last reviewed: 2026-08-07
|
||||||
|
|
||||||
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
|
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
|
||||||
|
|
||||||
@@ -16,8 +16,7 @@ This file tracks the gap between the native `@opencode-ai/ai` package and the AI
|
|||||||
| Native slice | Source | Current state | Main gaps |
|
| Native slice | Source | Current state | Main gaps |
|
||||||
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. |
|
| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. |
|
||||||
| OpenAI Responses HTTP | `src/protocols/open-responses.ts`, `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Extends the Open Responses baseline with hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
|
| OpenAI Responses | `src/protocols/open-responses.ts`, `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable over HTTP by default, with optional per-call WebSocket channel execution on the same model and route identity. | No incremental `previous_response_id` path or persistent Session channel manager yet. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
|
||||||
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
|
|
||||||
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
|
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
|
||||||
| Open Responses-compatible | `src/protocols/open-responses.ts`, `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the provider-neutral Open Responses protocol. The deployment adapter does not inherit OpenAI tools, events, metadata, or defaults. | No named family profiles or recorded deployment coverage yet. |
|
| Open Responses-compatible | `src/protocols/open-responses.ts`, `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the provider-neutral Open Responses protocol. The deployment adapter does not inherit OpenAI tools, events, metadata, or defaults. | No named family profiles or recorded deployment coverage yet. |
|
||||||
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. |
|
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. |
|
||||||
@@ -48,19 +47,19 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
|
|||||||
|
|
||||||
## AI SDK Package Parity Matrix
|
## AI SDK Package Parity Matrix
|
||||||
|
|
||||||
| AI SDK package | Intended native target | Status | Biggest gaps |
|
| AI SDK package | Intended native target | Status | Biggest gaps |
|
||||||
| --------------------------------- | -------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------- | --------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses`, `OpenAI.responsesWebSocket` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner route selection between Chat/Responses/WebSocket. |
|
| `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner execution policy for optional WebSocket channels. |
|
||||||
| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat and Responses | Partial / usable | Decide per-family namespace/profile behavior and runner API selection for providers that support Responses versus Chat only. |
|
| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat and Responses | Partial / usable | Decide per-family namespace/profile behavior and runner API selection for providers that support Responses versus Chat only. |
|
||||||
| `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. |
|
| `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. |
|
||||||
| `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
|
| `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
|
||||||
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. |
|
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. |
|
||||||
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. |
|
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. |
|
||||||
| `@ai-sdk/google-vertex/maas` | Vertex Chat | Partial / usable | Add runner/catalog mapping, recorded coverage, and MaaS family-specific request parity. |
|
| `@ai-sdk/google-vertex/maas` | Vertex Chat | Partial / usable | Add runner/catalog mapping, recorded coverage, and MaaS family-specific request parity. |
|
||||||
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
|
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
|
||||||
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
|
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
|
||||||
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
|
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
|
||||||
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
|
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
|
||||||
|
|
||||||
## Highest-Risk Gaps
|
## Highest-Risk Gaps
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ Examples:
|
|||||||
```ts
|
```ts
|
||||||
OpenAI.responses("gpt-4o")
|
OpenAI.responses("gpt-4o")
|
||||||
OpenAI.chat("gpt-4o")
|
OpenAI.chat("gpt-4o")
|
||||||
OpenAI.responsesWebSocket("gpt-4o")
|
|
||||||
|
|
||||||
Azure.configure({ resourceName, apiKey }).responses("my-deployment")
|
Azure.configure({ resourceName, apiKey }).responses("my-deployment")
|
||||||
AmazonBedrock.configure({ region, credentials }).model("anthropic.claude-3-5-sonnet-20241022-v2:0")
|
AmazonBedrock.configure({ region, credentials }).model("anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||||
@@ -250,11 +249,6 @@ const openAIChat = Route.make({
|
|||||||
auth: Auth.envBearer("OPENAI_API_KEY"),
|
auth: Auth.envBearer("OPENAI_API_KEY"),
|
||||||
})
|
})
|
||||||
|
|
||||||
const openAIResponsesWebSocket = openAIResponses.with({
|
|
||||||
id: "openai-responses-websocket",
|
|
||||||
transport: WebSocketTransport.json,
|
|
||||||
})
|
|
||||||
|
|
||||||
const openAIConfig = (input: OpenAIConfig) => ({
|
const openAIConfig = (input: OpenAIConfig) => ({
|
||||||
endpoint: input.endpoint,
|
endpoint: input.endpoint,
|
||||||
auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined),
|
auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined),
|
||||||
@@ -266,13 +260,11 @@ const openAIConfig = (input: OpenAIConfig) => ({
|
|||||||
|
|
||||||
const configureOpenAI = (input: OpenAIConfig = {}) => {
|
const configureOpenAI = (input: OpenAIConfig = {}) => {
|
||||||
const responses = openAIResponses.with(openAIConfig(input))
|
const responses = openAIResponses.with(openAIConfig(input))
|
||||||
const responsesWebSocket = openAIResponsesWebSocket.with(openAIConfig(input))
|
|
||||||
const chat = openAIChat.with(openAIConfig(input))
|
const chat = openAIChat.with(openAIConfig(input))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: openAIProvider,
|
id: openAIProvider,
|
||||||
responses: responses.model,
|
responses: responses.model,
|
||||||
responsesWebSocket: responsesWebSocket.model,
|
|
||||||
chat: chat.model,
|
chat: chat.model,
|
||||||
model: responses.model,
|
model: responses.model,
|
||||||
configure: configureOpenAI,
|
configure: configureOpenAI,
|
||||||
@@ -342,22 +334,19 @@ const response =
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
For direct provider-facade calls, HTTP versus WebSocket is represented as named
|
For direct provider-facade calls, Responses has one semantic model and route:
|
||||||
route selectors, not as model or request overrides. Same protocol, different
|
|
||||||
transport, different route:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
OpenAI.responses("gpt-4o")
|
OpenAI.responses("gpt-4o")
|
||||||
OpenAI.responsesWebSocket("gpt-4o")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The package-like OpenAI Responses entrypoint instead keeps transport scoped to
|
The package-like OpenAI Responses entrypoint has the same transport-neutral
|
||||||
Responses settings while preserving the same `model(...)` contract:
|
`model(...)` contract:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||||
|
|
||||||
model("gpt-4o", { apiKey, transport: "websocket" })
|
model("gpt-4o", { apiKey })
|
||||||
```
|
```
|
||||||
|
|
||||||
Vertex keeps Gemini, Chat, Responses, and Messages as separate package-like entrypoints,
|
Vertex keeps Gemini, Chat, Responses, and Messages as separate package-like entrypoints,
|
||||||
@@ -499,16 +488,13 @@ generic dynamic resolver:
|
|||||||
const model =
|
const model =
|
||||||
providerID === "azure"
|
providerID === "azure"
|
||||||
? Azure.configure(resolvedAzureConfig).responses(apiModelID)
|
? Azure.configure(resolvedAzureConfig).responses(apiModelID)
|
||||||
: endpoint.websocket
|
: OpenAI.responses(apiModelID)
|
||||||
? OpenAI.responsesWebSocket(apiModelID)
|
|
||||||
: OpenAI.responses(apiModelID)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
That boundary can branch on durable config/catalog metadata and call typed
|
That boundary can branch on durable config/catalog metadata and call typed
|
||||||
provider APIs directly. A direct provider-facade boundary maps metadata like
|
provider APIs directly. Transport selection remains execution policy: a Session
|
||||||
`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`. A package-loading
|
or other caller may pass a WebSocket channel executor per call without changing
|
||||||
boundary passes `transport: "websocket"` to the OpenAI Responses entrypoint.
|
the model constructed by this boundary.
|
||||||
The client runtime only executes the route carried by the resulting model.
|
|
||||||
|
|
||||||
## Competitive Shape
|
## Competitive Shape
|
||||||
|
|
||||||
@@ -544,9 +530,8 @@ App boundary = explicit durable-config -> typed-provider call
|
|||||||
id.
|
id.
|
||||||
- No `model(id, overrides)` escape hatch. Model selection takes the model id;
|
- No `model(id, overrides)` escape hatch. Model selection takes the model id;
|
||||||
endpoint/auth/deployment customization happens by configuring the route first.
|
endpoint/auth/deployment customization happens by configuring the route first.
|
||||||
- No transport override on an executable model or request. Direct provider
|
- No transport setting on a provider or executable model. OpenAI Responses uses
|
||||||
facades use `responses` versus `responsesWebSocket`; the package-like Responses
|
HTTP by default and accepts an optional per-call channel executor as execution policy.
|
||||||
entrypoint maps its scoped `transport` setting before constructing the model.
|
|
||||||
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
|
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
|
||||||
client layer with the available transport capabilities.
|
client layer with the available transport capabilities.
|
||||||
- No executable `ModelRef`. The executable handle is `LanguageModel`; durable model
|
- No executable `ModelRef`. The executable handle is `LanguageModel`; durable model
|
||||||
@@ -580,12 +565,10 @@ App boundary = explicit durable-config -> typed-provider call
|
|||||||
- [x] Make unconfigured transports reusable constants such as
|
- [x] Make unconfigured transports reusable constants such as
|
||||||
`HttpTransport.sseJson`; keep transport functions only for configured/fresh
|
`HttpTransport.sseJson`; keep transport functions only for configured/fresh
|
||||||
state construction.
|
state construction.
|
||||||
- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer`
|
- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer` accepts
|
||||||
exposes available transport capabilities and selected routes fail with typed
|
optional per-call channel execution without changing route identity.
|
||||||
transport config errors when a required capability is missing.
|
|
||||||
- [x] Convert OpenAI provider APIs to provider-facade shape:
|
- [x] Convert OpenAI provider APIs to provider-facade shape:
|
||||||
`OpenAI.configure(config).responses(id)`, `.chat(id)`, and
|
`OpenAI.configure(config).responses(id)` and `.chat(id)`.
|
||||||
`.responsesWebSocket(id)`.
|
|
||||||
- [x] Convert Azure to a configured facade where resource/base URL/api version
|
- [x] Convert Azure to a configured facade where resource/base URL/api version
|
||||||
setup happens before selecting deployment ids.
|
setup happens before selecting deployment ids.
|
||||||
- [x] Split Cloudflare products into separate facades such as
|
- [x] Split Cloudflare products into separate facades such as
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
|
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
|
||||||
import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
|
import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
|
||||||
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/ai/route"
|
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode-ai/ai/route"
|
||||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -214,8 +214,7 @@ const FakeEcho = {
|
|||||||
// enabled at a time so the tutorial can demonstrate generate, stream, or
|
// enabled at a time so the tutorial can demonstrate generate, stream, or
|
||||||
// tool-loop behavior without spending tokens on every example.
|
// tool-loop behavior without spending tokens on every example.
|
||||||
const requestExecutorLayer = RequestExecutor.fetchLayer
|
const requestExecutorLayer = RequestExecutor.fetchLayer
|
||||||
const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
||||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
|
|
||||||
|
|
||||||
const program = Effect.gen(function* () {
|
const program = Effect.gen(function* () {
|
||||||
// yield* generateOnce
|
// yield* generateOnce
|
||||||
@@ -223,6 +222,6 @@ const program = Effect.gen(function* () {
|
|||||||
// yield* generateStructuredObject
|
// yield* generateStructuredObject
|
||||||
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
|
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
|
||||||
yield* streamWithTools
|
yield* streamWithTools
|
||||||
}).pipe(Effect.provide(Layer.mergeAll(llmDeps, llmClientLayer)))
|
}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer)))
|
||||||
|
|
||||||
Effect.runPromise(program)
|
Effect.runPromise(program)
|
||||||
|
|||||||
@@ -211,11 +211,43 @@ export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
|||||||
// event-level `error` envelope, so accept all three shapes here.
|
// event-level `error` envelope, so accept all three shapes here.
|
||||||
// https://www.openresponses.org/specification
|
// https://www.openresponses.org/specification
|
||||||
const OpenResponsesErrorPayload = Schema.Struct({
|
const OpenResponsesErrorPayload = Schema.Struct({
|
||||||
|
type: optionalNull(Schema.String),
|
||||||
code: optionalNull(Schema.String),
|
code: optionalNull(Schema.String),
|
||||||
message: optionalNull(Schema.String),
|
message: optionalNull(Schema.String),
|
||||||
param: optionalNull(Schema.String),
|
param: optionalNull(Schema.String),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
|
||||||
|
export const WebSocketErrorEvent = Schema.StructWithRest(
|
||||||
|
Schema.Struct({
|
||||||
|
type: Schema.tag("error"),
|
||||||
|
status: Schema.optional(Schema.Number),
|
||||||
|
status_code: Schema.optional(Schema.Number),
|
||||||
|
code: optionalNull(Schema.String),
|
||||||
|
message: Schema.optional(Schema.String),
|
||||||
|
param: optionalNull(Schema.String),
|
||||||
|
error: optionalNull(OpenResponsesErrorPayload),
|
||||||
|
headers: Schema.optional(Schema.Record(Schema.String, WebSocketErrorHeader)),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
)
|
||||||
|
const decodeWebSocketErrorEvent = Schema.decodeUnknownEffect(WebSocketErrorEvent)
|
||||||
|
|
||||||
|
export const decodeKnownErrorEvent = (event: Event) =>
|
||||||
|
decodeWebSocketErrorEvent({
|
||||||
|
...event,
|
||||||
|
status: typeof event.status === "number" ? event.status : undefined,
|
||||||
|
status_code: typeof event.status_code === "number" ? event.status_code : undefined,
|
||||||
|
headers: ProviderShared.isRecord(event.headers)
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(event.headers).filter(
|
||||||
|
(entry): entry is [string, string | number | boolean] =>
|
||||||
|
typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
export const Event = Schema.StructWithRest(
|
export const Event = Schema.StructWithRest(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.String,
|
type: Schema.String,
|
||||||
@@ -240,6 +272,9 @@ export const Event = Schema.StructWithRest(
|
|||||||
message: Schema.optional(Schema.String),
|
message: Schema.optional(Schema.String),
|
||||||
param: optionalNull(Schema.String),
|
param: optionalNull(Schema.String),
|
||||||
error: optionalNull(OpenResponsesErrorPayload),
|
error: optionalNull(OpenResponsesErrorPayload),
|
||||||
|
status: Schema.optional(Schema.Unknown),
|
||||||
|
status_code: Schema.optional(Schema.Unknown),
|
||||||
|
headers: Schema.optional(Schema.Unknown),
|
||||||
}),
|
}),
|
||||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
)
|
)
|
||||||
@@ -632,9 +667,9 @@ export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
|||||||
const NO_EVENTS: StepResult["1"] = []
|
const NO_EVENTS: StepResult["1"] = []
|
||||||
|
|
||||||
// `response.completed` / `response.incomplete` are clean finishes that emit a
|
// `response.completed` / `response.incomplete` are clean finishes that emit a
|
||||||
// `finish` event; `response.failed` is a hard failure. All three end the stream,
|
// `finish` event; `response.failed` and `error` are hard failures. All four end
|
||||||
// so keep this set aligned with `step` and the protocol's terminal predicate.
|
// the stream, so keep this set aligned with `step` and the protocol's terminal predicate.
|
||||||
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incomplete", "response.failed"])
|
||||||
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
|
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
|
||||||
|
|
||||||
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
|
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
|
||||||
@@ -966,16 +1001,24 @@ const providerErrorMessage = (event: Event, fallback: string): string => {
|
|||||||
return message || code || fallback
|
return message || code || fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerError = (state: ParserState, event: Event, fallback: string) => {
|
export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||||
const code = event.code || event.error?.code || event.response?.error?.code || undefined
|
const code = event.code || event.error?.code || event.response?.error?.code || undefined
|
||||||
const message = providerErrorMessage(event, fallback)
|
const message = providerErrorMessage(event, fallback)
|
||||||
|
const status =
|
||||||
|
typeof event.status === "number"
|
||||||
|
? event.status
|
||||||
|
: typeof event.status_code === "number"
|
||||||
|
? event.status_code
|
||||||
|
: undefined
|
||||||
return new AIError({
|
return new AIError({
|
||||||
module: state.id,
|
module: id,
|
||||||
method: "stream",
|
method: "stream",
|
||||||
reason: classifyProviderFailure({ message, code }),
|
reason: classifyProviderFailure({ message, code, status }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
|
||||||
|
|
||||||
export const step = (state: ParserState, event: Event) => {
|
export const step = (state: ParserState, event: Event) => {
|
||||||
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
|
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
|
||||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||||
@@ -1015,7 +1058,11 @@ export const step = (state: ParserState, event: Event) => {
|
|||||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||||
return Effect.succeed(onResponseFinish(state, event))
|
return Effect.succeed(onResponseFinish(state, event))
|
||||||
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
||||||
if (event.type === "error") return providerError(state, event, `${state.name} stream error`)
|
if (event.type === "error")
|
||||||
|
return decodeKnownErrorEvent(event).pipe(
|
||||||
|
Effect.mapError(() => ProviderShared.eventError(state.id, `${state.name} returned a malformed error event`)),
|
||||||
|
Effect.flatMap(() => providerError(state, event, `${state.name} stream error`)),
|
||||||
|
)
|
||||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.Struct({
|
||||||
Schema.StructWithRest(
|
cached_tokens: Schema.optional(Schema.Number),
|
||||||
Schema.Struct({
|
cache_write_tokens: Schema.optional(Schema.Number),
|
||||||
cached_tokens: optionalNull(Schema.Number),
|
}),
|
||||||
cache_write_tokens: optionalNull(Schema.Number),
|
),
|
||||||
}),
|
completion_tokens_details: optionalNull(
|
||||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
Schema.Struct({
|
||||||
),
|
reasoning_tokens: Schema.optional(Schema.Number),
|
||||||
),
|
}),
|
||||||
completion_tokens_details: optionalNull(
|
),
|
||||||
Schema.StructWithRest(
|
})
|
||||||
Schema.Struct({
|
|
||||||
reasoning_tokens: optionalNull(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
|
: state.finishReason
|
||||||
? { normalized: mapFinishReason(rawFinishReason), raw: choice?.native_finish_reason ?? rawFinishReason }
|
|
||||||
: 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,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
import type { WebSocketChannelDriver } from "../route/transport"
|
||||||
|
import * as ProviderShared from "./shared"
|
||||||
|
import { OpenResponses } from "./open-responses"
|
||||||
|
|
||||||
|
const ADAPTER = "openai-responses"
|
||||||
|
const NAME = "OpenAI Responses"
|
||||||
|
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||||
|
|
||||||
|
export const make = (message: string): WebSocketChannelDriver => ({
|
||||||
|
create: () => Effect.succeed({ message, mode: "full" }),
|
||||||
|
observe: (_create, frame) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const event = yield* decodeEvent(frame).pipe(
|
||||||
|
Effect.mapError(() => ProviderShared.eventError(ADAPTER, "Invalid OpenAI Responses WebSocket event", frame)),
|
||||||
|
)
|
||||||
|
if (event.type === "response.completed") return { type: "completed", frame }
|
||||||
|
if (event.type === "response.incomplete") return { type: "incomplete", frame }
|
||||||
|
if (event.type === "response.failed")
|
||||||
|
return {
|
||||||
|
type: "provider-failure",
|
||||||
|
error: OpenResponses.providerFailure(ADAPTER, event, `${NAME} response failed`),
|
||||||
|
}
|
||||||
|
if (event.type === "error") {
|
||||||
|
yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
|
||||||
|
Effect.mapError(() => ProviderShared.eventError(ADAPTER, `${NAME} returned a malformed error event`, frame)),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
type: "provider-failure",
|
||||||
|
error: OpenResponses.providerFailure(ADAPTER, event, `${NAME} stream error`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { type: "frame", frame }
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const OpenAIResponsesChannel = { make } as const
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
import { Effect, Encoding, Schema } from "effect"
|
import { Effect, Encoding, Schema, Stream } from "effect"
|
||||||
|
import { Headers } from "effect/unstable/http"
|
||||||
import { Route } from "../route/client"
|
import { Route } from "../route/client"
|
||||||
import { Auth } from "../route/auth"
|
import { Auth } from "../route/auth"
|
||||||
import { Endpoint } from "../route/endpoint"
|
import { Endpoint } from "../route/endpoint"
|
||||||
|
import { Framing } from "../route/framing"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import { HttpTransport, WebSocketTransport } from "../route/transport"
|
import {
|
||||||
|
HttpTransport,
|
||||||
|
WebSocketTransport,
|
||||||
|
type Transport,
|
||||||
|
type WebSocketChannelDriver,
|
||||||
|
type WebSocketChannelExchange,
|
||||||
|
} from "../route/transport"
|
||||||
import { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema"
|
import { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema"
|
||||||
import { OpenResponses } from "./open-responses"
|
import { OpenResponses } from "./open-responses"
|
||||||
import { optionalArray, ProviderShared } from "./shared"
|
import { optionalArray, ProviderShared } from "./shared"
|
||||||
import { Lifecycle } from "./utils/lifecycle"
|
import { Lifecycle } from "./utils/lifecycle"
|
||||||
import { OpenAIImage } from "./utils/openai-image"
|
import { OpenAIImage } from "./utils/openai-image"
|
||||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||||
|
import { OpenAIResponsesChannel } from "./openai-responses-channel"
|
||||||
|
|
||||||
const ADAPTER = "openai-responses"
|
const ADAPTER = "openai-responses"
|
||||||
const NAME = "OpenAI Responses"
|
const NAME = "OpenAI Responses"
|
||||||
@@ -250,17 +259,6 @@ const auth = Auth.none
|
|||||||
|
|
||||||
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
|
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
|
||||||
|
|
||||||
export const route = Route.make({
|
|
||||||
id: ADAPTER,
|
|
||||||
provider: "openai",
|
|
||||||
providerMetadataKey: "openai",
|
|
||||||
protocol,
|
|
||||||
endpoint,
|
|
||||||
auth,
|
|
||||||
transport: httpTransport,
|
|
||||||
defaults: { providerOptions: { openai: { store: false } } },
|
|
||||||
})
|
|
||||||
|
|
||||||
const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
|
const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
|
||||||
|
|
||||||
const webSocketMessage = (body: OpenAIResponsesBody | Record<string, unknown>) =>
|
const webSocketMessage = (body: OpenAIResponsesBody | Record<string, unknown>) =>
|
||||||
@@ -271,22 +269,58 @@ const webSocketMessage = (body: OpenAIResponsesBody | Record<string, unknown>) =
|
|||||||
return yield* decodeWebSocketMessage({ ...message, type: "response.create" })
|
return yield* decodeWebSocketMessage({ ...message, type: "response.create" })
|
||||||
})
|
})
|
||||||
|
|
||||||
export const webSocketTransport = WebSocketTransport.jsonTransport.with<
|
export interface OpenAIResponsesPrepared {
|
||||||
OpenAIResponsesBody,
|
readonly http: HttpTransport.HttpPrepared<string>
|
||||||
OpenAIResponsesWebSocketMessage
|
readonly channel?: {
|
||||||
>({
|
readonly url: string
|
||||||
toMessage: webSocketMessage,
|
readonly headers: Headers.Headers
|
||||||
encodeMessage: encodeWebSocketMessage,
|
readonly driver: WebSocketChannelDriver
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const webSocketRoute = Route.make({
|
export const transport: Transport<OpenAIResponsesBody, OpenAIResponsesPrepared, string> = {
|
||||||
id: `${ADAPTER}-websocket`,
|
id: httpTransport.id,
|
||||||
|
prepare: (input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const parts = yield* HttpTransport.jsonRequestParts(input)
|
||||||
|
return {
|
||||||
|
http: {
|
||||||
|
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
||||||
|
framing: Framing.sse,
|
||||||
|
middleware: input.middleware,
|
||||||
|
},
|
||||||
|
channel: input.webSocket
|
||||||
|
? {
|
||||||
|
url: yield* WebSocketTransport.toWebSocketUrl(parts.url),
|
||||||
|
headers: parts.headers,
|
||||||
|
driver: OpenAIResponsesChannel.make(encodeWebSocketMessage(yield* webSocketMessage(parts.jsonBody))),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
execute: (prepared, request, runtime, options) => {
|
||||||
|
if (!options?.webSocket || !prepared.channel) return httpTransport.execute(prepared.http, request, runtime)
|
||||||
|
const exchange: WebSocketChannelExchange = {
|
||||||
|
id: request.id ?? "request",
|
||||||
|
connect: { url: prepared.channel.url, headers: prepared.channel.headers },
|
||||||
|
fallback: () =>
|
||||||
|
Stream.unwrap(
|
||||||
|
httpTransport.execute(prepared.http, request, runtime).pipe(Effect.map((execution) => execution.frames)),
|
||||||
|
),
|
||||||
|
driver: prepared.channel.driver,
|
||||||
|
}
|
||||||
|
return options.webSocket.execute(exchange)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const route = Route.make({
|
||||||
|
id: ADAPTER,
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
providerMetadataKey: "openai",
|
providerMetadataKey: "openai",
|
||||||
protocol,
|
protocol,
|
||||||
endpoint,
|
endpoint,
|
||||||
auth,
|
auth,
|
||||||
transport: webSocketTransport,
|
transport,
|
||||||
defaults: { providerOptions: { openai: { store: false } } },
|
defaults: { providerOptions: { openai: { store: false } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const SERVER_CODES = new Set([
|
|||||||
"overloaded_error",
|
"overloaded_error",
|
||||||
"server_error",
|
"server_error",
|
||||||
"server_is_overloaded",
|
"server_is_overloaded",
|
||||||
|
"slow_down",
|
||||||
"serviceunavailableexception",
|
"serviceunavailableexception",
|
||||||
])
|
])
|
||||||
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
|
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
|
||||||
|
|||||||
@@ -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 & {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type { OpenAIImageOptions } from "../protocols/openai-images"
|
|||||||
|
|
||||||
export const id = ProviderID.make("openai")
|
export const id = ProviderID.make("openai")
|
||||||
|
|
||||||
export const routes = [OpenAIResponses.route, OpenAIResponses.webSocketRoute, OpenAIChat.route]
|
export const routes = [OpenAIResponses.route, OpenAIChat.route]
|
||||||
|
|
||||||
// This provider facade wraps the lower-level Responses and Chat model factories
|
// This provider facade wraps the lower-level Responses and Chat model factories
|
||||||
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
|
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
|
||||||
@@ -63,7 +63,6 @@ export interface Settings extends ProviderPackage.Settings {
|
|||||||
readonly organization?: string
|
readonly organization?: string
|
||||||
readonly project?: string
|
readonly project?: string
|
||||||
readonly queryParams?: Readonly<Record<string, string>>
|
readonly queryParams?: Readonly<Record<string, string>>
|
||||||
readonly transport?: "http" | "websocket"
|
|
||||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,17 +81,12 @@ const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Co
|
|||||||
|
|
||||||
export const configure = (input: Config = {}) => {
|
export const configure = (input: Config = {}) => {
|
||||||
const responsesRoute = configuredRoute(OpenAIResponses.route, input)
|
const responsesRoute = configuredRoute(OpenAIResponses.route, input)
|
||||||
const responsesWebSocketRoute = configuredRoute(OpenAIResponses.webSocketRoute, input)
|
|
||||||
const chatRoute = configuredRoute(OpenAIChat.route, input)
|
const chatRoute = configuredRoute(OpenAIChat.route, input)
|
||||||
const modelDefaults = defaults(input)
|
const modelDefaults = defaults(input)
|
||||||
const responses = (id: string | ModelID) =>
|
const responses = (id: string | ModelID) =>
|
||||||
responsesRoute
|
responsesRoute
|
||||||
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
||||||
.model<OpenAIProviderOptionsInput>({ id })
|
.model<OpenAIProviderOptionsInput>({ id })
|
||||||
const responsesWebSocket = (id: string | ModelID) =>
|
|
||||||
responsesWebSocketRoute
|
|
||||||
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
|
||||||
.model<OpenAIProviderOptionsInput>({ id })
|
|
||||||
const chat = (id: string | ModelID) =>
|
const chat = (id: string | ModelID) =>
|
||||||
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
|
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
|
||||||
const image = (modelID: string | ModelID) =>
|
const image = (modelID: string | ModelID) =>
|
||||||
@@ -111,7 +105,6 @@ export const configure = (input: Config = {}) => {
|
|||||||
id,
|
id,
|
||||||
model: responses,
|
model: responses,
|
||||||
responses,
|
responses,
|
||||||
responsesWebSocket,
|
|
||||||
chat,
|
chat,
|
||||||
image,
|
image,
|
||||||
configure,
|
configure,
|
||||||
@@ -138,10 +131,7 @@ const config = (settings: Settings): Config => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||||
const configured = configure(config(settings))
|
return configure(config(settings)).responses(modelID)
|
||||||
if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID)
|
|
||||||
if (settings.transport === "websocket") return configured.responsesWebSocket(modelID)
|
|
||||||
throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||||
@@ -149,6 +139,5 @@ export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptio
|
|||||||
settings,
|
settings,
|
||||||
) => configure(config(settings)).chat(modelID)
|
) => configure(config(settings)).chat(modelID)
|
||||||
export const responses = provider.responses
|
export const responses = provider.responses
|
||||||
export const responsesWebSocket = provider.responsesWebSocket
|
|
||||||
export const chat = provider.chat
|
export const chat = provider.chat
|
||||||
export const image = provider.image
|
export const image = provider.image
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
|
||||||
import * as Option from "effect/Option"
|
|
||||||
import { Auth } from "./auth"
|
import { Auth } from "./auth"
|
||||||
import { Endpoint, type EndpointPatch } from "./endpoint"
|
import { Endpoint, type EndpointPatch } from "./endpoint"
|
||||||
import { RequestExecutor } from "./executor"
|
import { RequestExecutor } from "./executor"
|
||||||
import { Framing } from "./framing"
|
import { Framing } from "./framing"
|
||||||
import { HttpTransport } from "./transport"
|
import { HttpTransport } from "./transport"
|
||||||
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
|
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport"
|
||||||
import { WebSocketExecutor } from "./transport"
|
|
||||||
import type { Protocol } from "./protocol"
|
import type { Protocol } from "./protocol"
|
||||||
import { applyCachePolicy } from "../cache-policy"
|
import { applyCachePolicy } from "../cache-policy"
|
||||||
import * as ProviderShared from "../protocols/shared"
|
import * as ProviderShared from "../protocols/shared"
|
||||||
@@ -58,6 +56,7 @@ export interface Route<Body, Prepared = unknown> {
|
|||||||
prepared: Prepared,
|
prepared: Prepared,
|
||||||
request: LLMRequest,
|
request: LLMRequest,
|
||||||
runtime: TransportRuntime,
|
runtime: TransportRuntime,
|
||||||
|
options?: StreamOptions,
|
||||||
) => Stream.Stream<LLMEvent, AIError>
|
) => Stream.Stream<LLMEvent, AIError>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +156,7 @@ export interface Interface {
|
|||||||
|
|
||||||
export interface StreamOptions {
|
export interface StreamOptions {
|
||||||
readonly http?: HttpMiddleware
|
readonly http?: HttpMiddleware
|
||||||
|
readonly webSocket?: WebSocketChannelExecutor
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamMethod {
|
export interface StreamMethod {
|
||||||
@@ -255,13 +255,7 @@ const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent,
|
|||||||
if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true
|
if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true
|
||||||
return Effect.succeed(event)
|
return Effect.succeed(event)
|
||||||
}),
|
}),
|
||||||
Stream.onEnd(
|
Stream.onEnd(Effect.suspend(() => (terminal ? Effect.void : Effect.fail(incompleteStreamError(route))))),
|
||||||
Effect.suspend(() =>
|
|
||||||
terminal
|
|
||||||
? Effect.void
|
|
||||||
: Effect.fail(incompleteStreamError(route)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -320,23 +314,29 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
|||||||
encodeBody,
|
encodeBody,
|
||||||
headers: routeInput.headers,
|
headers: routeInput.headers,
|
||||||
middleware: options?.http,
|
middleware: options?.http,
|
||||||
|
webSocket: options?.webSocket,
|
||||||
}),
|
}),
|
||||||
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime, options?: StreamOptions) => {
|
||||||
const route = `${request.model.provider}/${request.model.route.id}`
|
const route = `${request.model.provider}/${request.model.route.id}`
|
||||||
const events = routeInput.transport
|
return Stream.unwrap(
|
||||||
.frames(prepared, request, runtime)
|
routeInput.transport.execute(prepared, request, runtime, options).pipe(
|
||||||
.pipe(
|
Effect.map((execution) => {
|
||||||
Stream.mapEffect(decodeEvent(route)),
|
const events = execution.frames.pipe(
|
||||||
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
Stream.mapEffect(decodeEvent(route)),
|
||||||
)
|
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
||||||
return events.pipe(
|
)
|
||||||
Stream.mapAccumEffect(
|
const stream = events.pipe(
|
||||||
() => protocol.stream.initial(request),
|
Stream.mapAccumEffect(
|
||||||
protocol.stream.step,
|
() => protocol.stream.initial(request),
|
||||||
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
|
protocol.stream.step,
|
||||||
|
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
|
||||||
|
),
|
||||||
|
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
||||||
|
requireTerminalEvent(route),
|
||||||
|
)
|
||||||
|
return execution.complete ? stream.pipe(Stream.onEnd(execution.complete)) : stream
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
|
||||||
requireTerminalEvent(route),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
} satisfies Route<Body, Prepared>
|
} satisfies Route<Body, Prepared>
|
||||||
@@ -419,7 +419,7 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, o
|
|||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const compiled = yield* compile(request, options)
|
const compiled = yield* compile(request, options)
|
||||||
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
|
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime, options)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -457,7 +457,6 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const stream = streamRequestWith({
|
const stream = streamRequestWith({
|
||||||
http: yield* RequestExecutor.Service,
|
http: yield* RequestExecutor.Service,
|
||||||
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
|
|
||||||
})
|
})
|
||||||
return Service.of({ stream, generate: generateWith(stream) })
|
return Service.of({ stream, generate: generateWith(stream) })
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -16,11 +16,28 @@ export { AuthOptions } from "./auth-options"
|
|||||||
export { Endpoint } from "./endpoint"
|
export { Endpoint } from "./endpoint"
|
||||||
export { Framing } from "./framing"
|
export { Framing } from "./framing"
|
||||||
export { Protocol } from "./protocol"
|
export { Protocol } from "./protocol"
|
||||||
export { HttpTransport, WebSocketExecutor, WebSocketTransport } from "./transport"
|
export { HttpTransport, WebSocketTransport } from "./transport"
|
||||||
export * as Transport from "./transport"
|
export * as Transport from "./transport"
|
||||||
export type { Definition as AuthShape, AuthInput, Credential, CredentialError } from "./auth"
|
export type { Definition as AuthShape, AuthInput, Credential, CredentialError } from "./auth"
|
||||||
export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options"
|
export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options"
|
||||||
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
|
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
|
||||||
export type { Definition as FramingDef } from "./framing"
|
export type { Definition as FramingDef } from "./framing"
|
||||||
export type { Protocol as ProtocolDef } from "./protocol"
|
export type { Protocol as ProtocolDef } from "./protocol"
|
||||||
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport"
|
export type {
|
||||||
|
ChannelCheckpoint,
|
||||||
|
ChannelCreate,
|
||||||
|
ChannelObservation,
|
||||||
|
HttpHandler,
|
||||||
|
HttpMiddleware,
|
||||||
|
Transport as TransportDef,
|
||||||
|
TransportExecuteOptions,
|
||||||
|
TransportExecution,
|
||||||
|
TransportRuntime,
|
||||||
|
WebSocketConnection,
|
||||||
|
WebSocketChannelDriver,
|
||||||
|
WebSocketChannelExchange,
|
||||||
|
WebSocketChannelExecution,
|
||||||
|
WebSocketChannelExecutor,
|
||||||
|
WebSocketConnector,
|
||||||
|
WebSocketRequest,
|
||||||
|
} from "./transport"
|
||||||
|
|||||||
@@ -86,26 +86,28 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
|||||||
middleware: prepareInput.middleware,
|
middleware: prepareInput.middleware,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
frames: (prepared, request, runtime) =>
|
execute: (prepared, request, runtime) =>
|
||||||
Stream.unwrap(
|
Effect.succeed({
|
||||||
runtime.http
|
frames: Stream.unwrap(
|
||||||
.execute(prepared.request, prepared.middleware)
|
runtime.http
|
||||||
.pipe(
|
.execute(prepared.request, prepared.middleware)
|
||||||
Effect.map((response) =>
|
.pipe(
|
||||||
prepared.framing.frame(
|
Effect.map((response) =>
|
||||||
response.stream.pipe(
|
prepared.framing.frame(
|
||||||
Stream.mapError((error) =>
|
response.stream.pipe(
|
||||||
ProviderShared.eventError(
|
Stream.mapError((error) =>
|
||||||
`${request.model.provider}/${request.model.route.id}`,
|
ProviderShared.eventError(
|
||||||
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
|
`${request.model.provider}/${request.model.route.id}`,
|
||||||
ProviderShared.errorText(error),
|
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
|
||||||
|
ProviderShared.errorText(error),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const sseJson = {
|
export const sseJson = {
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
import type { Effect, Stream } from "effect"
|
import type { Effect, Scope, Stream } from "effect"
|
||||||
import { Endpoint } from "../endpoint"
|
import { Endpoint } from "../endpoint"
|
||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor"
|
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor"
|
||||||
import type { Interface as WebSocketExecutorInterface } from "./websocket"
|
import type { WebSocketChannelExecutor } from "./websocket-channel"
|
||||||
import type { AIError, LLMRequest } from "../../schema"
|
import type { AIError, LLMRequest } from "../../schema"
|
||||||
|
|
||||||
export interface TransportRuntime {
|
export interface TransportRuntime {
|
||||||
readonly http: RequestExecutorInterface
|
readonly http: RequestExecutorInterface
|
||||||
readonly webSocket?: WebSocketExecutorInterface
|
}
|
||||||
|
|
||||||
|
export interface TransportExecution<Frame> {
|
||||||
|
readonly frames: Stream.Stream<Frame, AIError>
|
||||||
|
/** Optional successful-consumption acknowledgement. HTTP leaves this absent. */
|
||||||
|
readonly complete?: Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransportExecuteOptions {
|
||||||
|
readonly webSocket?: WebSocketChannelExecutor
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Transport<Body, Prepared, Frame> {
|
export interface Transport<Body, Prepared, Frame> {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
|
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
|
||||||
readonly frames: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => Stream.Stream<Frame, AIError>
|
readonly execute: (
|
||||||
|
prepared: Prepared,
|
||||||
|
request: LLMRequest,
|
||||||
|
runtime: TransportRuntime,
|
||||||
|
options?: TransportExecuteOptions,
|
||||||
|
) => Effect.Effect<TransportExecution<Frame>, AIError, Scope.Scope>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TransportPrepareInput<Body> {
|
export interface TransportPrepareInput<Body> {
|
||||||
@@ -24,8 +38,19 @@ export interface TransportPrepareInput<Body> {
|
|||||||
readonly encodeBody: (body: Body) => string
|
readonly encodeBody: (body: Body) => string
|
||||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||||
readonly middleware?: HttpMiddleware
|
readonly middleware?: HttpMiddleware
|
||||||
|
readonly webSocket?: WebSocketChannelExecutor
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as HttpTransport from "./http"
|
export * as HttpTransport from "./http"
|
||||||
export type { HttpHandler, HttpMiddleware } from "../executor"
|
export type { HttpHandler, HttpMiddleware } from "../executor"
|
||||||
export { WebSocketExecutor, WebSocketTransport } from "./websocket"
|
export type {
|
||||||
|
ChannelCheckpoint,
|
||||||
|
ChannelCreate,
|
||||||
|
ChannelObservation,
|
||||||
|
WebSocketChannelDriver,
|
||||||
|
WebSocketChannelExchange,
|
||||||
|
WebSocketChannelExecution,
|
||||||
|
WebSocketChannelExecutor,
|
||||||
|
} from "./websocket-channel"
|
||||||
|
export type { WebSocketConnection, WebSocketConnector, WebSocketRequest } from "./websocket"
|
||||||
|
export { WebSocketTransport } from "./websocket"
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Effect, Scope, Stream } from "effect"
|
||||||
|
import type { Headers } from "effect/unstable/http"
|
||||||
|
import type { AIError } from "../../schema"
|
||||||
|
|
||||||
|
export interface WebSocketChannelExecutor {
|
||||||
|
readonly execute: (
|
||||||
|
exchange: WebSocketChannelExchange,
|
||||||
|
) => Effect.Effect<WebSocketChannelExecution, AIError, Scope.Scope>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebSocketChannelExecution {
|
||||||
|
readonly frames: Stream.Stream<string, AIError>
|
||||||
|
/** Commits staged state after the decoded Route stream ends successfully. */
|
||||||
|
readonly complete: Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebSocketChannelExchange {
|
||||||
|
readonly id: string
|
||||||
|
readonly connect: {
|
||||||
|
readonly url: string
|
||||||
|
readonly headers: Headers.Headers
|
||||||
|
}
|
||||||
|
readonly fallback: () => Stream.Stream<string, AIError>
|
||||||
|
readonly driver: WebSocketChannelDriver
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebSocketChannelDriver {
|
||||||
|
readonly create: (checkpoint: ChannelCheckpoint | undefined) => Effect.Effect<ChannelCreate, AIError>
|
||||||
|
readonly observe: (create: ChannelCreate, frame: string) => Effect.Effect<ChannelObservation, AIError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelCreate {
|
||||||
|
readonly message: string
|
||||||
|
readonly mode: "full" | "incremental"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChannelObservation =
|
||||||
|
| { readonly type: "frame"; readonly frame: string }
|
||||||
|
| { readonly type: "completed"; readonly frame: string; readonly checkpoint?: ChannelCheckpoint }
|
||||||
|
| { readonly type: "incomplete"; readonly frame: string }
|
||||||
|
| { readonly type: "provider-failure"; readonly error: AIError }
|
||||||
|
| { readonly type: "rejected"; readonly error: AIError; readonly recovery: "retry-full" }
|
||||||
|
| { readonly type: "rejected"; readonly error: AIError; readonly recovery: "rotate-and-retry-full" }
|
||||||
|
|
||||||
|
export interface ChannelCheckpoint {
|
||||||
|
readonly protocol: string
|
||||||
|
readonly value: unknown
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
import { Cause, Effect, Queue, Stream } from "effect"
|
||||||
import { Headers } from "effect/unstable/http"
|
import { Headers } from "effect/unstable/http"
|
||||||
|
import { Socket } from "effect/unstable/socket"
|
||||||
import { AIError, TransportReason } from "../../schema"
|
import { AIError, TransportReason } from "../../schema"
|
||||||
import * as HttpTransport from "./http"
|
import * as HttpTransport from "./http"
|
||||||
import type { Transport } from "./index"
|
import type { Transport } from "./index"
|
||||||
|
import type {
|
||||||
|
ChannelObservation,
|
||||||
|
WebSocketChannelDriver,
|
||||||
|
WebSocketChannelExchange,
|
||||||
|
WebSocketChannelExecutor,
|
||||||
|
} from "./websocket-channel"
|
||||||
|
|
||||||
export interface WebSocketRequest {
|
export interface WebSocketRequest {
|
||||||
readonly url: string
|
readonly url: string
|
||||||
@@ -15,28 +22,57 @@ export interface WebSocketConnection {
|
|||||||
readonly close: Effect.Effect<void, never>
|
readonly close: Effect.Effect<void, never>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface WebSocketConnector {
|
||||||
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
|
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebSocketConstructorWithHeaders = new (
|
type WebSocketConstructorWithHeaders = (
|
||||||
url: string,
|
url: string,
|
||||||
options?: { readonly headers?: Headers.Headers },
|
options?: { readonly headers?: Headers.Headers },
|
||||||
) => globalThis.WebSocket
|
) => globalThis.WebSocket
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/WebSocketExecutor") {}
|
|
||||||
|
|
||||||
const transportError = (
|
const transportError = (
|
||||||
method: string,
|
method: string,
|
||||||
message: string,
|
message: string,
|
||||||
input: { readonly url?: string; readonly kind?: string } = {},
|
input: {
|
||||||
|
readonly url?: string
|
||||||
|
readonly kind?: string
|
||||||
|
readonly phase?: TransportReason["phase"]
|
||||||
|
readonly delivery?: TransportReason["delivery"]
|
||||||
|
} = {},
|
||||||
) =>
|
) =>
|
||||||
new AIError({
|
new AIError({
|
||||||
module: "WebSocketExecutor",
|
module: "WebSocketConnector",
|
||||||
method,
|
method,
|
||||||
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
|
reason: new TransportReason({
|
||||||
|
message,
|
||||||
|
url: input.url,
|
||||||
|
kind: input.kind,
|
||||||
|
phase: input.phase,
|
||||||
|
delivery: input.delivery,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const annotateTransportError = (
|
||||||
|
error: AIError,
|
||||||
|
input: { readonly phase: TransportReason["phase"]; readonly delivery: TransportReason["delivery"] },
|
||||||
|
) =>
|
||||||
|
error.reason._tag === "Transport"
|
||||||
|
? new AIError({
|
||||||
|
module: error.module,
|
||||||
|
method: error.method,
|
||||||
|
reason: new TransportReason({
|
||||||
|
message: error.reason.message,
|
||||||
|
kind: error.reason.kind,
|
||||||
|
url: error.reason.url,
|
||||||
|
http: error.reason.http,
|
||||||
|
phase: input.phase,
|
||||||
|
delivery: input.delivery,
|
||||||
|
recovery: error.reason.recovery,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
: error
|
||||||
|
|
||||||
const eventMessage = (event: Event) => {
|
const eventMessage = (event: Event) => {
|
||||||
if ("message" in event && typeof event.message === "string") return event.message
|
if ("message" in event && typeof event.message === "string") return event.message
|
||||||
return event.type
|
return event.type
|
||||||
@@ -56,6 +92,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
|||||||
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
|
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
kind: "open",
|
kind: "open",
|
||||||
|
phase: "connect",
|
||||||
|
delivery: "not-sent",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -79,7 +117,12 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
|||||||
cleanup()
|
cleanup()
|
||||||
resume(
|
resume(
|
||||||
Effect.fail(
|
Effect.fail(
|
||||||
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { url: input.url, kind: "open" }),
|
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
|
||||||
|
url: input.url,
|
||||||
|
kind: "open",
|
||||||
|
phase: "connect",
|
||||||
|
delivery: "not-sent",
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -90,6 +133,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
|||||||
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
|
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
kind: "open",
|
kind: "open",
|
||||||
|
phase: "connect",
|
||||||
|
delivery: "not-sent",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -101,7 +146,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const webSocketUrl = (value: string) =>
|
export const toWebSocketUrl = (value: string) =>
|
||||||
Effect.try({
|
Effect.try({
|
||||||
try: () => {
|
try: () => {
|
||||||
const url = new URL(value)
|
const url = new URL(value)
|
||||||
@@ -119,21 +164,31 @@ const webSocketUrl = (value: string) =>
|
|||||||
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
|
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
|
||||||
url: value,
|
url: value,
|
||||||
kind: "websocket",
|
kind: "websocket",
|
||||||
|
phase: "prepare",
|
||||||
|
delivery: "not-sent",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const open = (input: WebSocketRequest) =>
|
export const open = (input: WebSocketRequest) =>
|
||||||
Effect.try({
|
Effect.gen(function* () {
|
||||||
try: () =>
|
const constructor = yield* Socket.WebSocketConstructor
|
||||||
new (globalThis.WebSocket as unknown as WebSocketConstructorWithHeaders)(input.url, { headers: input.headers }),
|
const ws = yield* Effect.try({
|
||||||
catch: (error) =>
|
try: () =>
|
||||||
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
// Platform implementations may extend Effect's browser-compatible constructor with handshake options.
|
||||||
url: input.url,
|
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||||
kind: "open",
|
(constructor as unknown as WebSocketConstructorWithHeaders)(input.url, {
|
||||||
}),
|
headers: input.headers,
|
||||||
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
|
}),
|
||||||
|
catch: (error) =>
|
||||||
export const layer: Layer.Layer<Service> = Layer.succeed(Service, Service.of({ open }))
|
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||||
|
url: input.url,
|
||||||
|
kind: "open",
|
||||||
|
phase: "connect",
|
||||||
|
delivery: "not-sent",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return yield* fromWebSocket(ws, input)
|
||||||
|
})
|
||||||
|
|
||||||
export const fromWebSocket = (
|
export const fromWebSocket = (
|
||||||
ws: globalThis.WebSocket,
|
ws: globalThis.WebSocket,
|
||||||
@@ -150,7 +205,11 @@ export const fromWebSocket = (
|
|||||||
Queue.failCauseUnsafe(
|
Queue.failCauseUnsafe(
|
||||||
messages,
|
messages,
|
||||||
Cause.fail(
|
Cause.fail(
|
||||||
transportError("message", "Unsupported WebSocket message payload", { url: input.url, kind: "message" }),
|
transportError("message", "Unsupported WebSocket message payload", {
|
||||||
|
url: input.url,
|
||||||
|
kind: "message",
|
||||||
|
phase: "receive",
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -158,16 +217,23 @@ export const fromWebSocket = (
|
|||||||
Queue.failCauseUnsafe(
|
Queue.failCauseUnsafe(
|
||||||
messages,
|
messages,
|
||||||
Cause.fail(
|
Cause.fail(
|
||||||
transportError("message", `WebSocket error: ${eventMessage(event)}`, { url: input.url, kind: "message" }),
|
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
|
||||||
|
url: input.url,
|
||||||
|
kind: "message",
|
||||||
|
phase: "receive",
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const onClose = (event: CloseEvent) => {
|
const onClose = (event: CloseEvent) => {
|
||||||
if (event.code === 1000 || event.code === 1005) return Queue.endUnsafe(messages)
|
|
||||||
Queue.failCauseUnsafe(
|
Queue.failCauseUnsafe(
|
||||||
messages,
|
messages,
|
||||||
Cause.fail(
|
Cause.fail(
|
||||||
transportError("message", `WebSocket closed with code ${event.code}`, { url: input.url, kind: "close" }),
|
transportError("message", `WebSocket closed with code ${event.code}`, {
|
||||||
|
url: input.url,
|
||||||
|
kind: "close",
|
||||||
|
phase: "close",
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -189,6 +255,8 @@ export const fromWebSocket = (
|
|||||||
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
kind: "write",
|
kind: "write",
|
||||||
|
phase: "send",
|
||||||
|
delivery: "not-sent",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
messages: Stream.fromQueue(messages),
|
messages: Stream.fromQueue(messages),
|
||||||
@@ -206,6 +274,57 @@ export const fromWebSocket = (
|
|||||||
export const messageText = (message: string | Uint8Array, decoder: TextDecoder) =>
|
export const messageText = (message: string | Uint8Array, decoder: TextDecoder) =>
|
||||||
typeof message === "string" ? message : decoder.decode(message)
|
typeof message === "string" ? message : decoder.decode(message)
|
||||||
|
|
||||||
|
const observationFrame = (observation: ChannelObservation) => {
|
||||||
|
if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
|
||||||
|
return Effect.succeed(observation.frame)
|
||||||
|
return Effect.fail(observation.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const observationTerminal = (observation: ChannelObservation) => observation.type !== "frame"
|
||||||
|
|
||||||
|
export const makeDirect = (connector: WebSocketConnector): WebSocketChannelExecutor => ({
|
||||||
|
execute: (exchange) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const connection = yield* Effect.acquireRelease(
|
||||||
|
connector
|
||||||
|
.open(exchange.connect)
|
||||||
|
.pipe(Effect.mapError((error) => annotateTransportError(error, { phase: "connect", delivery: "not-sent" }))),
|
||||||
|
(connection) => connection.close,
|
||||||
|
)
|
||||||
|
const create = yield* exchange.driver.create(undefined)
|
||||||
|
yield* connection.sendText(create.message)
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let observed = false
|
||||||
|
return {
|
||||||
|
frames: connection.messages.pipe(
|
||||||
|
Stream.map((message) => {
|
||||||
|
observed = true
|
||||||
|
return messageText(message, decoder)
|
||||||
|
}),
|
||||||
|
Stream.mapError((error) =>
|
||||||
|
annotateTransportError(error, {
|
||||||
|
phase: error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
|
||||||
|
delivery: observed ? "accepted" : "ambiguous",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
|
||||||
|
Stream.takeUntil(observationTerminal),
|
||||||
|
Stream.mapEffect(observationFrame),
|
||||||
|
),
|
||||||
|
complete: Effect.void,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const direct: Effect.Effect<WebSocketChannelExecutor, never, Socket.WebSocketConstructor> = Effect.gen(
|
||||||
|
function* () {
|
||||||
|
const constructor = yield* Socket.WebSocketConstructor
|
||||||
|
return makeDirect({
|
||||||
|
open: (input) => open(input).pipe(Effect.provideService(Socket.WebSocketConstructor, constructor)),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
export interface JsonPrepared {
|
export interface JsonPrepared {
|
||||||
readonly url: string
|
readonly url: string
|
||||||
readonly headers: Headers.Headers
|
readonly headers: Headers.Headers
|
||||||
@@ -232,32 +351,42 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
|||||||
...prepareInput,
|
...prepareInput,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
url: yield* webSocketUrl(parts.url),
|
url: yield* toWebSocketUrl(parts.url),
|
||||||
headers: parts.headers,
|
headers: parts.headers,
|
||||||
message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
|
message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
frames: (prepared, _request, runtime) => {
|
execute: (prepared, request, _runtime, options) => {
|
||||||
const webSocket = runtime.webSocket
|
const webSocket = options?.webSocket
|
||||||
if (!webSocket) {
|
if (!webSocket) {
|
||||||
return Stream.fail(
|
return Effect.fail(
|
||||||
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
|
transportError("json", "WebSocket JSON transport requires StreamOptions.webSocket", {
|
||||||
url: prepared.url,
|
url: prepared.url,
|
||||||
kind: "websocket",
|
kind: "websocket",
|
||||||
|
phase: "prepare",
|
||||||
|
delivery: "not-sent",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const decoder = new TextDecoder()
|
const driver: WebSocketChannelDriver = {
|
||||||
return Stream.unwrap(
|
create: () => Effect.succeed({ message: prepared.message, mode: "full" }),
|
||||||
Effect.gen(function* () {
|
observe: (_create, frame) => Effect.succeed({ type: "frame", frame }),
|
||||||
const connection = yield* Effect.acquireRelease(
|
}
|
||||||
webSocket.open({ url: prepared.url, headers: prepared.headers }),
|
const exchange: WebSocketChannelExchange = {
|
||||||
(connection) => connection.close,
|
id: request.id ?? "request",
|
||||||
)
|
connect: { url: prepared.url, headers: prepared.headers },
|
||||||
yield* connection.sendText(prepared.message)
|
fallback: () =>
|
||||||
return connection.messages.pipe(Stream.map((message) => messageText(message, decoder)))
|
Stream.fail(
|
||||||
}),
|
transportError("fallback", "WebSocket JSON transport does not provide HTTP fallback", {
|
||||||
)
|
url: prepared.url,
|
||||||
|
kind: "websocket",
|
||||||
|
phase: "fallback",
|
||||||
|
delivery: "not-sent",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
driver,
|
||||||
|
}
|
||||||
|
return webSocket.execute(exchange)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -266,15 +395,13 @@ export const jsonTransport = {
|
|||||||
with: json,
|
with: json,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const WebSocketExecutor = {
|
|
||||||
Service,
|
|
||||||
layer,
|
|
||||||
open,
|
|
||||||
fromWebSocket,
|
|
||||||
messageText,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export const WebSocketTransport = {
|
export const WebSocketTransport = {
|
||||||
json,
|
json,
|
||||||
jsonTransport,
|
jsonTransport,
|
||||||
|
direct,
|
||||||
|
makeDirect,
|
||||||
|
open,
|
||||||
|
fromWebSocket,
|
||||||
|
messageText,
|
||||||
|
toWebSocketUrl,
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Tra
|
|||||||
kind: Schema.optional(Schema.String),
|
kind: Schema.optional(Schema.String),
|
||||||
url: Schema.optional(Schema.String),
|
url: Schema.optional(Schema.String),
|
||||||
http: Schema.optional(HttpContext),
|
http: Schema.optional(HttpContext),
|
||||||
|
phase: Schema.optional(
|
||||||
|
Schema.Literals(["prepare", "queue", "connect", "send", "receive", "decode", "complete", "fallback", "close"]),
|
||||||
|
),
|
||||||
|
delivery: Schema.optional(Schema.Literals(["not-sent", "rejected", "ambiguous", "accepted"])),
|
||||||
|
recovery: Schema.optional(
|
||||||
|
Schema.Literals(["retry-connect", "retry-full", "rotate-and-retry-full", "fallback-http", "fail"]),
|
||||||
|
),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
|
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer, Ref } from "effect"
|
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
|
||||||
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { LLM, AIError } from "../src"
|
import { LLM, AIError } from "../src"
|
||||||
import { LLMClient, RequestExecutor } from "../src/route"
|
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route"
|
||||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||||
import { dynamicResponse } from "./lib/http"
|
import * as OpenAI from "../src/providers/openai"
|
||||||
|
import { dynamicResponse, fixedResponse } from "./lib/http"
|
||||||
import { deltaChunk } from "./lib/openai-chunks"
|
import { deltaChunk } from "./lib/openai-chunks"
|
||||||
import { sseRaw } from "./lib/sse"
|
import { sseRaw } from "./lib/sse"
|
||||||
import { it } from "./lib/effect"
|
import { it } from "./lib/effect"
|
||||||
@@ -413,3 +414,125 @@ describe("RequestExecutor", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("WebSocket channel execution", () => {
|
||||||
|
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
|
||||||
|
const request = LLM.request({ model, prompt: "Say hello." })
|
||||||
|
const frames = [
|
||||||
|
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||||
|
JSON.stringify({ type: "response.completed", response: { id: "resp_1" } }),
|
||||||
|
]
|
||||||
|
|
||||||
|
it.effect("runs a channel driver through the direct executor", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const sent = yield* Ref.make("")
|
||||||
|
const closed = yield* Ref.make(false)
|
||||||
|
const observed = yield* Ref.make(0)
|
||||||
|
const webSocket = WebSocketTransport.makeDirect({
|
||||||
|
open: () =>
|
||||||
|
Effect.succeed({
|
||||||
|
sendText: (message) => Ref.set(sent, message),
|
||||||
|
messages: Stream.make("one", "done", "late"),
|
||||||
|
close: Ref.set(closed, true),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const received = yield* Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const execution = yield* webSocket.execute({
|
||||||
|
id: "exchange_1",
|
||||||
|
connect: { url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
|
||||||
|
fallback: () => Stream.empty,
|
||||||
|
driver: {
|
||||||
|
create: () => Effect.succeed({ message: "create", mode: "full" }),
|
||||||
|
observe: (_create, frame) =>
|
||||||
|
Ref.update(observed, (value) => value + 1).pipe(
|
||||||
|
Effect.as(
|
||||||
|
frame === "done" ? { type: "completed" as const, frame } : { type: "frame" as const, frame },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return yield* Stream.runCollect(execution.frames)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(Array.from(received)).toEqual(["one", "done"])
|
||||||
|
expect(yield* Ref.get(sent)).toBe("create")
|
||||||
|
expect(yield* Ref.get(observed)).toBe(2)
|
||||||
|
expect(yield* Ref.get(closed)).toBe(true)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("requires a per-call WebSocket executor", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse("")), Effect.flip)
|
||||||
|
|
||||||
|
expect(error.reason).toMatchObject({
|
||||||
|
_tag: "Transport",
|
||||||
|
phase: "prepare",
|
||||||
|
delivery: "not-sent",
|
||||||
|
})
|
||||||
|
expect(error.message).toContain("StreamOptions.webSocket")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("commits channel execution only after complete consumption", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const commits = yield* Ref.make(0)
|
||||||
|
const executor = (input: Stream.Stream<string, AIError>): WebSocketChannelExecutor => ({
|
||||||
|
execute: () =>
|
||||||
|
Effect.succeed({
|
||||||
|
frames: input,
|
||||||
|
complete: Ref.update(commits, (value) => value + 1),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = yield* LLMClient.generate(request, {
|
||||||
|
webSocket: executor(Stream.fromArray(frames)),
|
||||||
|
}).pipe(Effect.provide(fixedResponse("")))
|
||||||
|
expect(response.text).toBe("Hi")
|
||||||
|
expect(yield* Ref.get(commits)).toBe(1)
|
||||||
|
|
||||||
|
yield* LLMClient.generate(request, { webSocket: executor(Stream.make("not-json")) }).pipe(
|
||||||
|
Effect.provide(fixedResponse("")),
|
||||||
|
Effect.flip,
|
||||||
|
)
|
||||||
|
expect(yield* Ref.get(commits)).toBe(1)
|
||||||
|
|
||||||
|
yield* LLMClient.stream(request, { webSocket: executor(Stream.fromArray(frames)) }).pipe(
|
||||||
|
Stream.take(1),
|
||||||
|
Stream.runDrain,
|
||||||
|
Effect.provide(fixedResponse("")),
|
||||||
|
)
|
||||||
|
expect(yield* Ref.get(commits)).toBe(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("does not commit interrupted channel execution", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const commits = yield* Ref.make(0)
|
||||||
|
const started = yield* Deferred.make<void>()
|
||||||
|
const executor: WebSocketChannelExecutor = {
|
||||||
|
execute: () =>
|
||||||
|
Effect.succeed({
|
||||||
|
frames: Stream.fromEffect(
|
||||||
|
Deferred.succeed(started, undefined).pipe(
|
||||||
|
Effect.as(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })),
|
||||||
|
),
|
||||||
|
).pipe(Stream.concat(Stream.never)),
|
||||||
|
complete: Ref.update(commits, (value) => value + 1),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const fiber = yield* LLMClient.stream(request, { webSocket: executor }).pipe(
|
||||||
|
Stream.runDrain,
|
||||||
|
Effect.provide(fixedResponse("")),
|
||||||
|
Effect.forkChild({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Deferred.await(started)
|
||||||
|
yield* Fiber.interrupt(fiber)
|
||||||
|
|
||||||
|
expect(yield* Ref.get(commits)).toBe(0)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@opencode-ai/ai"
|
import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@opencode-ai/ai"
|
||||||
import { Route, Protocol } from "@opencode-ai/ai/route"
|
import { Route, Protocol, WebSocketTransport } from "@opencode-ai/ai/route"
|
||||||
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
|
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
|
||||||
import {
|
import {
|
||||||
CloudflareAIGateway,
|
CloudflareAIGateway,
|
||||||
@@ -37,6 +37,7 @@ describe("public exports", () => {
|
|||||||
test("route barrel exposes route-authoring APIs", () => {
|
test("route barrel exposes route-authoring APIs", () => {
|
||||||
expect(Route.make).toBeFunction()
|
expect(Route.make).toBeFunction()
|
||||||
expect(Protocol.make).toBeFunction()
|
expect(Protocol.make).toBeFunction()
|
||||||
|
expect(WebSocketTransport.makeDirect).toBeFunction()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("provider barrels expose user-facing facades", async () => {
|
test("provider barrels expose user-facing facades", async () => {
|
||||||
@@ -44,7 +45,6 @@ describe("public exports", () => {
|
|||||||
|
|
||||||
expect(OpenAI.model).toBeFunction()
|
expect(OpenAI.model).toBeFunction()
|
||||||
expect(OpenAI.provider.responses).toBe(OpenAI.responses)
|
expect(OpenAI.provider.responses).toBe(OpenAI.responses)
|
||||||
expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket)
|
|
||||||
expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction()
|
expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction()
|
||||||
expect(OpenAICompatible.deepseek.model).toBeFunction()
|
expect(OpenAICompatible.deepseek.model).toBeFunction()
|
||||||
expect(
|
expect(
|
||||||
@@ -86,7 +86,6 @@ describe("public exports", () => {
|
|||||||
expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses")
|
expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses")
|
||||||
expect(OpenAICompatibleResponses.route.protocol).toBe("open-responses")
|
expect(OpenAICompatibleResponses.route.protocol).toBe("open-responses")
|
||||||
expect(OpenAIResponses.route.id).toBe("openai-responses")
|
expect(OpenAIResponses.route.id).toBe("openai-responses")
|
||||||
expect(OpenAIResponses.webSocketRoute.id).toBe("openai-responses-websocket")
|
|
||||||
expect(AnthropicMessages.route.id).toBe("anthropic-messages")
|
expect(AnthropicMessages.route.id).toBe("anthropic-messages")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Effect, Layer, Ref } from "effect"
|
import { Effect, Layer, Ref } from "effect"
|
||||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
import { LLMClient, RequestExecutor } from "../../src/route"
|
||||||
import type { Service as LLMClientService } from "../../src/route/client"
|
import type { Service as LLMClientService } from "../../src/route/client"
|
||||||
import type { Service as RequestExecutorService } from "../../src/route/executor"
|
import type { Service as RequestExecutorService } from "../../src/route/executor"
|
||||||
import type { Service as WebSocketExecutorService } from "../../src/route/transport/websocket"
|
|
||||||
|
|
||||||
export type HandlerInput = {
|
export type HandlerInput = {
|
||||||
readonly request: HttpClientRequest.HttpClientRequest
|
readonly request: HttpClientRequest.HttpClientRequest
|
||||||
@@ -32,13 +31,12 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
|
export type RuntimeEnv = RequestExecutorService | LLMClientService
|
||||||
|
|
||||||
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
|
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
|
||||||
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
|
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
|
||||||
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
||||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(deps))
|
return Layer.mergeAll(requestExecutorLayer, llmClientLayer)
|
||||||
return Layer.mergeAll(deps, llmClientLayer)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SSE_HEADERS = { "content-type": "text/event-stream" } as const
|
const SSE_HEADERS = { "content-type": "text/event-stream" } as const
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ describe("provider error classification", () => {
|
|||||||
|
|
||||||
test("classifies V1 overloaded provider codes", () => {
|
test("classifies V1 overloaded provider codes", () => {
|
||||||
expect(
|
expect(
|
||||||
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}'].map(
|
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}', '{"code":"slow_down"}'].map(
|
||||||
(message) => classifyProviderFailure({ message })._tag,
|
(message) => classifyProviderFailure({ message })._tag,
|
||||||
),
|
),
|
||||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("classifies transient client statuses as provider internal", () => {
|
test("classifies transient client statuses as provider internal", () => {
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { LLM } from "../../src"
|
import { LLM } from "../../src"
|
||||||
import { OpenAI } from "../../src/providers"
|
import { OpenAI } from "../../src/providers"
|
||||||
|
|
||||||
const model = OpenAI.responses("gpt-5")
|
const selected = OpenAI.responses("gpt-5")
|
||||||
|
|
||||||
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
|
LLM.request({ model: selected, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
|
||||||
|
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model,
|
model: selected,
|
||||||
prompt: "Hello",
|
prompt: "Hello",
|
||||||
// @ts-expect-error OpenAI reasoning effort must be a string.
|
// @ts-expect-error OpenAI reasoning effort must be a string.
|
||||||
providerOptions: { openai: { reasoningEffort: 1 } },
|
providerOptions: { openai: { reasoningEffort: 1 } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
OpenAI.configure({
|
||||||
|
// @ts-expect-error Transport is execution policy, not provider configuration.
|
||||||
|
transport: "websocket",
|
||||||
|
})
|
||||||
|
|||||||
@@ -80,11 +80,6 @@ describe("provider package entrypoints", () => {
|
|||||||
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("selects transport without changing the semantic API", () => {
|
|
||||||
expect(model("gpt-5", { apiKey: "fixture" }).route.id).toBe("openai-responses")
|
|
||||||
expect(model("gpt-5", { apiKey: "fixture", transport: "websocket" }).route.id).toBe("openai-responses-websocket")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
|
test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
|
||||||
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
|
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
|
||||||
const selected = OpenAICompatibleResponses.model("custom-model", {
|
const selected = OpenAICompatibleResponses.model("custom-model", {
|
||||||
@@ -209,27 +204,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({
|
|
||||||
accessToken: "vertex-token",
|
|
||||||
location: "eu",
|
|
||||||
project: "vertex-project",
|
|
||||||
}).model("claude-sonnet-4-6")
|
|
||||||
const response = yield* LLMClient.generate(
|
const response = yield* LLMClient.generate(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model,
|
model: GoogleVertexMessages.configure({
|
||||||
|
accessToken: "vertex-token",
|
||||||
|
location: "eu",
|
||||||
|
project: "vertex-project",
|
||||||
|
}).model("claude-sonnet-4-6"),
|
||||||
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")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
import { ConfigProvider, Effect, Layer, Ref, Stream } from "effect"
|
||||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
import {
|
import {
|
||||||
LLM,
|
LLM,
|
||||||
AIError,
|
AIError,
|
||||||
|
HttpOptions,
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
LLMRequest,
|
LLMRequest,
|
||||||
Message,
|
Message,
|
||||||
@@ -11,9 +12,10 @@ import {
|
|||||||
ToolCallPart,
|
ToolCallPart,
|
||||||
ToolDefinition,
|
ToolDefinition,
|
||||||
ToolResultPart,
|
ToolResultPart,
|
||||||
|
TransportReason,
|
||||||
Usage,
|
Usage,
|
||||||
} from "../../src"
|
} from "../../src"
|
||||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
import { Auth, LLMClient, RequestExecutor, WebSocketTransport } from "../../src/route"
|
||||||
import { compileRequest } from "../../src/route/client"
|
import { compileRequest } from "../../src/route/client"
|
||||||
import * as Azure from "../../src/providers/azure"
|
import * as Azure from "../../src/providers/azure"
|
||||||
import * as OpenAI from "../../src/providers/openai"
|
import * as OpenAI from "../../src/providers/openai"
|
||||||
@@ -216,19 +218,19 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("prepares OpenAI Responses WebSocket target", () =>
|
it.effect("prepares one OpenAI Responses route for either transport", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
LLMRequest.update(request, {
|
LLMRequest.update(request, {
|
||||||
model: OpenAIResponses.webSocketRoute
|
model: OpenAIResponses.route
|
||||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||||
.model({ id: "gpt-4.1-mini" }),
|
.model({ id: "gpt-4.1-mini" }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(prepared.route).toBe("openai-responses-websocket")
|
expect(prepared.route).toBe("openai-responses")
|
||||||
expect(prepared.protocol).toBe("openai-responses")
|
expect(prepared.protocol).toBe("openai-responses")
|
||||||
expect(prepared.metadata).toEqual({ transport: "websocket-json" })
|
expect(prepared.metadata).toEqual({ transport: "http-json" })
|
||||||
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true })
|
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -238,41 +240,35 @@ describe("OpenAI Responses route", () => {
|
|||||||
const sent: string[] = []
|
const sent: string[] = []
|
||||||
const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
|
const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
|
||||||
let closed = false
|
let closed = false
|
||||||
const deps = Layer.mergeAll(
|
const deps = Layer.succeed(
|
||||||
Layer.succeed(
|
RequestExecutor.Service,
|
||||||
RequestExecutor.Service,
|
RequestExecutor.Service.of({
|
||||||
RequestExecutor.Service.of({
|
execute: () => Effect.die("unexpected HTTP request"),
|
||||||
execute: () => Effect.die("unexpected HTTP request"),
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
Layer.succeed(
|
|
||||||
WebSocketExecutor.Service,
|
|
||||||
WebSocketExecutor.Service.of({
|
|
||||||
open: (input) =>
|
|
||||||
Effect.succeed({
|
|
||||||
sendText: (message) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
opened.push({ url: input.url, authorization: input.headers.authorization })
|
|
||||||
sent.push(message)
|
|
||||||
}),
|
|
||||||
messages: Stream.fromArray([
|
|
||||||
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
|
||||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
|
|
||||||
]),
|
|
||||||
close: Effect.sync(() => {
|
|
||||||
closed = true
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
const webSocket = WebSocketTransport.makeDirect({
|
||||||
|
open: (input) =>
|
||||||
|
Effect.succeed({
|
||||||
|
sendText: (message) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
opened.push({ url: input.url, authorization: input.headers.authorization })
|
||||||
|
sent.push(message)
|
||||||
|
}),
|
||||||
|
messages: Stream.fromArray([
|
||||||
|
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||||
|
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
|
||||||
|
]),
|
||||||
|
close: Effect.sync(() => {
|
||||||
|
closed = true
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
const response = yield* LLMClient.generate(
|
const response = yield* LLMClient.generate(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"),
|
||||||
"gpt-4.1-mini",
|
|
||||||
),
|
|
||||||
prompt: "Say hello.",
|
prompt: "Say hello.",
|
||||||
}),
|
}),
|
||||||
|
{ webSocket },
|
||||||
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
|
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
|
||||||
|
|
||||||
expect(response.text).toBe("Hi")
|
expect(response.text).toBe("Hi")
|
||||||
@@ -288,15 +284,235 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("builds WebSocket and HTTP fallback from the same final request", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const attempts = yield* Ref.make(0)
|
||||||
|
const message = yield* Ref.make("")
|
||||||
|
const body = yield* Ref.make("")
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.request({
|
||||||
|
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"),
|
||||||
|
prompt: "Say hello.",
|
||||||
|
http: {
|
||||||
|
body: { model: "overlaid-model", metadata: { source: "overlay" } },
|
||||||
|
headers: { "x-request": "request" },
|
||||||
|
query: { mode: "test" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
webSocket: {
|
||||||
|
execute: (exchange) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* exchange.driver
|
||||||
|
.create(undefined)
|
||||||
|
.pipe(Effect.flatMap((create) => Ref.set(message, create.message)))
|
||||||
|
return { frames: exchange.fallback(), complete: Effect.void }
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Ref.update(attempts, (value) => value + 1)
|
||||||
|
yield* Ref.set(body, input.text)
|
||||||
|
expect(input.request.url).toBe("https://api.openai.test/v1/responses?mode=test")
|
||||||
|
expect(input.request.headers.authorization).toBe("Bearer test")
|
||||||
|
expect(input.request.headers["x-request"]).toBe("request")
|
||||||
|
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const httpBody = JSON.parse(yield* Ref.get(body))
|
||||||
|
const { stream: _stream, ...shared } = httpBody
|
||||||
|
expect(response.finishReason?.normalized).toBe("stop")
|
||||||
|
expect(yield* Ref.get(attempts)).toBe(1)
|
||||||
|
expect(JSON.parse(yield* Ref.get(message))).toEqual({ type: "response.create", ...shared })
|
||||||
|
expect(httpBody).toMatchObject({
|
||||||
|
model: "overlaid-model",
|
||||||
|
metadata: { source: "overlay" },
|
||||||
|
stream: true,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("uses exactly one HTTP request when no WebSocket executor is supplied", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const attempts = yield* Ref.make(0)
|
||||||
|
yield* LLMClient.generate(
|
||||||
|
LLMRequest.update(request, { http: new HttpOptions({ body: { input: "raw-http-input" } }) }),
|
||||||
|
).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Ref.update(attempts, (value) => value + 1)
|
||||||
|
expect(JSON.parse(input.text).input).toBe("raw-http-input")
|
||||||
|
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(yield* Ref.get(attempts)).toBe(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("closes a direct WebSocket execution after partial consumption", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const closed = yield* Ref.make(false)
|
||||||
|
const webSocket = WebSocketTransport.makeDirect({
|
||||||
|
open: () =>
|
||||||
|
Effect.succeed({
|
||||||
|
sendText: () => Effect.void,
|
||||||
|
messages: Stream.fromArray([
|
||||||
|
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||||
|
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
|
||||||
|
]),
|
||||||
|
close: Ref.set(closed, true),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* LLMClient.stream(
|
||||||
|
LLM.request({
|
||||||
|
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"),
|
||||||
|
prompt: "Say hello.",
|
||||||
|
}),
|
||||||
|
{ webSocket },
|
||||||
|
).pipe(
|
||||||
|
Stream.take(1),
|
||||||
|
Stream.runDrain,
|
||||||
|
Effect.provide(
|
||||||
|
LLMClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
Layer.succeed(
|
||||||
|
RequestExecutor.Service,
|
||||||
|
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(yield* Ref.get(closed)).toBe(true)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("terminates WebSocket control events without waiting for the socket to close", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = [
|
||||||
|
{ type: "error", error: { code: "slow_down", message: "Try later" } },
|
||||||
|
{
|
||||||
|
type: "error",
|
||||||
|
status_code: 429,
|
||||||
|
message: "Rate limited",
|
||||||
|
headers: { "retry-after": 1, "x-request-id": "request", cached: false, invalid: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.failed",
|
||||||
|
response: { error: { code: "server_error", message: "Unavailable" } },
|
||||||
|
},
|
||||||
|
{ type: "error", status: "not-a-status", message: "Malformed status" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const errors = yield* Effect.forEach(events, (event) =>
|
||||||
|
LLMClient.generate(
|
||||||
|
LLM.request({
|
||||||
|
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
|
||||||
|
"gpt-4.1-mini",
|
||||||
|
),
|
||||||
|
prompt: "Say hello.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
webSocket: WebSocketTransport.makeDirect({
|
||||||
|
open: () =>
|
||||||
|
Effect.succeed({
|
||||||
|
sendText: () => Effect.void,
|
||||||
|
messages: Stream.make(ProviderShared.encodeJson(event)).pipe(Stream.concat(Stream.never)),
|
||||||
|
close: Effect.void,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
LLMClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
Layer.succeed(
|
||||||
|
RequestExecutor.Service,
|
||||||
|
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.flip,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(errors.map((error) => error.reason._tag)).toEqual([
|
||||||
|
"ProviderInternal",
|
||||||
|
"RateLimit",
|
||||||
|
"ProviderInternal",
|
||||||
|
"UnknownProvider",
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("marks post-send WebSocket failures with delivery state", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const failure = new AIError({
|
||||||
|
module: "test",
|
||||||
|
method: "receive",
|
||||||
|
reason: new TransportReason({ message: "socket closed", phase: "close" }),
|
||||||
|
})
|
||||||
|
const streams = [
|
||||||
|
Stream.fail(failure),
|
||||||
|
Stream.make(ProviderShared.encodeJson({ type: "response.created" })).pipe(Stream.concat(Stream.fail(failure))),
|
||||||
|
]
|
||||||
|
const deps = Layer.succeed(
|
||||||
|
RequestExecutor.Service,
|
||||||
|
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||||
|
)
|
||||||
|
const webSocket = WebSocketTransport.makeDirect({
|
||||||
|
open: () =>
|
||||||
|
Effect.succeed({
|
||||||
|
sendText: () => Effect.void,
|
||||||
|
messages: streams.shift() ?? Stream.die("unexpected WebSocket open"),
|
||||||
|
close: Effect.void,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
|
||||||
|
"gpt-4.1-mini",
|
||||||
|
)
|
||||||
|
|
||||||
|
const errors = yield* Effect.forEach(["first", "second"], (prompt) =>
|
||||||
|
LLMClient.generate(LLM.request({ model, prompt }), { webSocket }).pipe(
|
||||||
|
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
|
||||||
|
Effect.flip,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(errors.map((error) => error.reason)).toEqual([
|
||||||
|
expect.objectContaining({ _tag: "Transport", phase: "close", delivery: "ambiguous" }),
|
||||||
|
expect.objectContaining({ _tag: "Transport", phase: "close", delivery: "accepted" }),
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("fails immediately when WebSocket is already closed", () =>
|
it.effect("fails immediately when WebSocket is already closed", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const error = yield* WebSocketExecutor.fromWebSocket(
|
const error = yield* WebSocketTransport.fromWebSocket(
|
||||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- fromWebSocket reads readyState before touching WebSocket methods on this branch.
|
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- fromWebSocket reads readyState before touching WebSocket methods on this branch.
|
||||||
{ readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket,
|
{ readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket,
|
||||||
{ url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
|
{ url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
|
||||||
).pipe(Effect.flip)
|
).pipe(Effect.flip)
|
||||||
|
|
||||||
expect(error.message).toContain("closed before opening")
|
expect(error.message).toContain("closed before opening")
|
||||||
|
expect(error.reason).toMatchObject({ _tag: "Transport", phase: "connect", delivery: "not-sent" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import { HttpRecorder } from "@opencode-ai/http-recorder"
|
|||||||
import { Layer } from "effect"
|
import { Layer } from "effect"
|
||||||
import * as path from "node:path"
|
import * as path from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route"
|
import { LLMClient, RequestExecutor } from "../src/route"
|
||||||
import { ImageClient } from "../src/image-client"
|
import { ImageClient } from "../src/image-client"
|
||||||
import type { Service as ImageClientService } from "../src/image-client"
|
import type { Service as ImageClientService } from "../src/image-client"
|
||||||
import type { Service as LLMClientService } from "../src/route/client"
|
import type { Service as LLMClientService } from "../src/route/client"
|
||||||
import type { Service as RequestExecutorService } from "../src/route/executor"
|
import type { Service as RequestExecutorService } from "../src/route/executor"
|
||||||
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
|
|
||||||
import {
|
import {
|
||||||
recordedEffectGroup,
|
recordedEffectGroup,
|
||||||
type RecordedCaseOptions as RunnerCaseOptions,
|
type RecordedCaseOptions as RunnerCaseOptions,
|
||||||
@@ -17,7 +16,7 @@ import {
|
|||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
||||||
|
|
||||||
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService
|
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
|
||||||
|
|
||||||
type RecordedTestsOptions = RecordedGroupOptions & {
|
type RecordedTestsOptions = RecordedGroupOptions & {
|
||||||
readonly options?: HttpRecorder.RecorderOptions
|
readonly options?: HttpRecorder.RecorderOptions
|
||||||
@@ -82,11 +81,10 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
|
|
||||||
return Layer.mergeAll(
|
return Layer.mergeAll(
|
||||||
deps,
|
requestExecutor,
|
||||||
LLMClient.layer.pipe(Layer.provide(deps)),
|
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||||
ImageClient.layer.pipe(Layer.provide(deps)),
|
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
LanguageModel,
|
LanguageModel,
|
||||||
ModelID,
|
ModelID,
|
||||||
ProviderID,
|
ProviderID,
|
||||||
|
TransportReason,
|
||||||
Usage,
|
Usage,
|
||||||
} from "../src/schema"
|
} from "../src/schema"
|
||||||
import { ProviderShared } from "../src/protocols/shared"
|
import { ProviderShared } from "../src/protocols/shared"
|
||||||
@@ -108,3 +109,21 @@ test("AI errors expose the shared runtime tag", async () => {
|
|||||||
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
|
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
|
||||||
).toBe("caught")
|
).toBe("caught")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("transport errors serialize execution facts", () => {
|
||||||
|
const reason = new TransportReason({
|
||||||
|
message: "connection closed",
|
||||||
|
phase: "receive",
|
||||||
|
delivery: "ambiguous",
|
||||||
|
recovery: "fail",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(Schema.encodeSync(TransportReason)(reason)).toEqual({
|
||||||
|
_tag: "Transport",
|
||||||
|
message: "connection closed",
|
||||||
|
phase: "receive",
|
||||||
|
delivery: "ambiguous",
|
||||||
|
recovery: "fail",
|
||||||
|
})
|
||||||
|
expect(Schema.decodeUnknownSync(TransportReason)(Schema.encodeSync(TransportReason)(reason))).toEqual(reason)
|
||||||
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,8 +177,7 @@ test("shows all and expands historical diff summary without overlap", async ({ p
|
|||||||
const firstUser = userMessage(undefined, {
|
const firstUser = userMessage(undefined, {
|
||||||
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}`,
|
||||||
|
|||||||
+10
-8
@@ -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)
|
||||||
|
|||||||
@@ -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 ?? {})
|
||||||
|
|||||||
@@ -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`))
|
||||||
controller.enqueue(
|
if (url.pathname === "/api/event")
|
||||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
controller.enqueue(
|
||||||
)
|
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",
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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:*",
|
||||||
|
|||||||
+181
-46
@@ -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,22 +577,26 @@ export function AppInterface(props: {
|
|||||||
<GlobalProvider>
|
<GlobalProvider>
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
||||||
<Dynamic
|
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
||||||
component={props.router ?? Router}
|
<Dynamic
|
||||||
root={(routerProps) => (
|
component={props.router ?? Router}
|
||||||
<TabsProvider>
|
root={(routerProps) => (
|
||||||
<PermissionProvider>
|
<TabsProvider>
|
||||||
<NotificationProvider>
|
<PermissionProvider>
|
||||||
<ServerShell>
|
<NotificationProvider>
|
||||||
<AppLayout serverScoped={props.serverScoped}>{routerProps.children}</AppLayout>
|
<ServerShell>
|
||||||
</ServerShell>
|
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||||
</NotificationProvider>
|
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||||
</PermissionProvider>
|
</Show>
|
||||||
</TabsProvider>
|
</ServerShell>
|
||||||
)}
|
</NotificationProvider>
|
||||||
>
|
</PermissionProvider>
|
||||||
<Routes />
|
</TabsProvider>
|
||||||
</Dynamic>
|
)}
|
||||||
|
>
|
||||||
|
<Routes serverScoped={props.serverScoped} />
|
||||||
|
</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} />
|
)}
|
||||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
>
|
||||||
|
<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} />
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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(() => [])
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">"{props.filter}"</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,10 +279,11 @@ export const SettingsGeneralV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
|
|
||||||
<SettingsRowV2
|
<Show when={protocol() === "v1"}>
|
||||||
title={language.t("settings.general.row.shell.title")}
|
<SettingsRowV2
|
||||||
description={language.t("settings.general.row.shell.description")}
|
title={language.t("settings.general.row.shell.title")}
|
||||||
>
|
description={language.t("settings.general.row.shell.description")}
|
||||||
|
>
|
||||||
<SelectV2
|
<SelectV2
|
||||||
appearance="inline"
|
appearance="inline"
|
||||||
data-action="settings-shell"
|
data-action="settings-shell"
|
||||||
@@ -270,7 +299,8 @@ export const SettingsGeneralV2: Component<{
|
|||||||
serverSync().updateConfig({ shell: option.value })
|
serverSync().updateConfig({ shell: option.value })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</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>
|
||||||
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
<Show when={protocol() === "v1"}>
|
||||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
||||||
{language.t("status.popover.tab.lsp")}
|
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||||
</Tabs.Trigger>
|
{language.t("status.popover.tab.lsp")}
|
||||||
<Show when={true}>
|
</Tabs.Trigger>
|
||||||
|
</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,7 +461,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
<Tabs.Content value="lsp">
|
<Show when={protocol() === "v1"}>
|
||||||
|
<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">
|
||||||
<Show
|
<Show
|
||||||
@@ -484,9 +488,10 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
</Show>
|
</Show>
|
||||||
</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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user