mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 09:39:46 -04:00
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55874ccaeb | |||
| b8ed70d6d3 | |||
| 2092350cfa | |||
| 5fb0d7c99c | |||
| a1b4843a33 | |||
| cc7827fe08 | |||
| 76e4d88d21 | |||
| 439ed66c7b | |||
| 047d434aa2 | |||
| d7651519f3 | |||
| 1eb3a43add | |||
| dcae95e2bb | |||
| 0cc507b8a9 | |||
| 727beae2d5 | |||
| cd64a17e37 | |||
| d35ca49c31 | |||
| 5cf97f1b96 | |||
| 65c6a71903 | |||
| a779027303 | |||
| 20fa444f31 | |||
| c66d84169a | |||
| 7dbe8c4c13 | |||
| 8864b01d0b | |||
| ec95b27308 | |||
| be2f74d44a | |||
| 912a801060 | |||
| 0215498f63 | |||
| 1539bd6794 | |||
| 939da6a5e7 | |||
| 0f27ee7b4c | |||
| 51cef27579 | |||
| a0c77b71ae | |||
| e62918224f | |||
| 30d140008f | |||
| a38d44265b | |||
| 0cce215de4 | |||
| a15ec425de | |||
| ae494ffce5 | |||
| 25c2150baf | |||
| 9314992f98 | |||
| 0fbe489e35 | |||
| 03e9789064 | |||
| 0c558a6856 | |||
| f297bd3b8b | |||
| 1bfc23f503 | |||
| 3839aafa25 | |||
| bd2f37bc90 | |||
| 120e4e7388 | |||
| 6f91bc7415 | |||
| c46f6ae112 | |||
| 285444aab4 | |||
| 56c33e84a3 | |||
| 73581b3c3b | |||
| 6e4d01f846 | |||
| 8646587c95 | |||
| fb47f06228 | |||
| 3acaa5a359 | |||
| 0726a25142 | |||
| 0df96ddeb0 | |||
| 887673310b | |||
| 693a1bff81 | |||
| b40ed3aa85 | |||
| 732bb9c3cb | |||
| 2fd1660b4d | |||
| 681526d348 | |||
| e78869c849 | |||
| 934935963d | |||
| f3912a2a8a | |||
| 45d58717a4 | |||
| 74e3155ef0 | |||
| 0af6c82563 | |||
| 686127f809 | |||
| 5256655c4d | |||
| d10b652637 | |||
| b03ca0d4e2 | |||
| 25aaea3d31 | |||
| cae7a139bc | |||
| 5ea62ab05f | |||
| faadc05c88 | |||
| 3e253c589e | |||
| 0a0fc09533 | |||
| 5aa0413fea | |||
| 6f4c199629 | |||
| ed8e1f4654 | |||
| 3b0195e045 | |||
| 143a776373 | |||
| 76b318e990 | |||
| c0ab35c3c2 |
@@ -111,6 +111,7 @@
|
|||||||
"@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:",
|
||||||
@@ -488,18 +489,6 @@
|
|||||||
"@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",
|
||||||
@@ -2066,8 +2055,6 @@
|
|||||||
|
|
||||||
"@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"],
|
||||||
|
|||||||
@@ -5,8 +5,27 @@
|
|||||||
- 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.
|
||||||
- Add explicit data operations to that migration where generated DDL is insufficient.
|
- Keep the canonical migration focused on schema changes and dropping obsolete tables.
|
||||||
- Test the migration against a populated database at the exact `dev` schema.
|
- Run the V1 history backfill through an experimental server endpoint invoked by the CLI before it opens the TUI.
|
||||||
|
- 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
|
||||||
|
|
||||||
@@ -15,20 +34,35 @@ 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.
|
||||||
|
|
||||||
Keep the `todo` table and its data unchanged. V2 does not currently migrate todos into another representation, and the
|
Preserve existing non-null `session.agent` and `session.model` selections. Fill missing values from the latest ordinary
|
||||||
generated migration must not drop the table.
|
V1 user message ordered by `time_created` and `id`, excluding compaction and subtask-only messages. Copy agent, provider
|
||||||
|
ID, model ID, and variant, normalizing an absent variant to `default`.
|
||||||
|
|
||||||
## Truncate
|
Recompute session usage aggregates from all canonical V1 assistant messages, including compaction or other internal
|
||||||
|
assistants omitted from the V2 projection. Overwrite session cost and input, output, reasoning, cache-read, and
|
||||||
|
cache-write token totals with those sums.
|
||||||
|
|
||||||
Truncate these pre-launch V2 tables before applying schema changes:
|
Clear persisted `session.revert` state. A staged revert is transient operational state and may refer to omitted projection
|
||||||
|
rows or unavailable snapshots; it must not resume automatically after upgrading. Preserve the underlying messages,
|
||||||
|
parts, and file history.
|
||||||
|
|
||||||
- `event`
|
Clear `session.time_compacting`, leave the new `time_suspended` column as `NULL`, and preserve session creation, update,
|
||||||
- `event_sequence`
|
and archive timestamps. Preserve project `time_initialized`; it is unrelated durable state.
|
||||||
- `session_message`
|
|
||||||
|
|
||||||
These rows are not canonical V1 data. Truncating `event` before adding the required `event.created` column means the
|
Keep the legacy `todo` table and its data physically unchanged, but do not include it in the final V2 Drizzle schema.
|
||||||
column needs neither a backfill nor a default. After truncation, rebuild `session_message` from canonical V1 `message`
|
After generation, remove the generated `DROP TABLE todo` statement from the canonical migration so the table remains as
|
||||||
and `part` rows rather than retaining its pre-launch V2 contents.
|
unmanaged legacy storage.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
@@ -36,9 +70,23 @@ 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`.
|
||||||
|
|
||||||
@@ -46,15 +94,122 @@ 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.
|
||||||
|
|
||||||
@@ -64,9 +219,13 @@ 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.
|
||||||
|
|
||||||
After rebuilding `session_message`, seed `event_sequence` with one row per migrated session. Set its watermark to that
|
Do not project incomplete or failed V1 compactions into `session_message`. Omit both the internal compaction user marker
|
||||||
session's maximum backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting
|
and its paired summary assistant when no successful summary was completed. Assign final sequence numbers after filtering
|
||||||
before migrated history. The `event` table remains empty.
|
so omitted compactions leave no gaps. Their source rows remain preserved in the V1 `message` and `part` tables.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
@@ -74,6 +233,7 @@ 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`.
|
||||||
|
|
||||||
@@ -103,16 +263,36 @@ 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.
|
||||||
|
|
||||||
## Verification
|
## Execution
|
||||||
|
|
||||||
The canonical migration test should seed representative V1 sessions, messages, parts, todos, projects, accounts,
|
Before transforming V1 rows, look for `opencode-next.db` in the data directory. This file was used by pre-launch V2
|
||||||
credentials, permissions, shares, and workspaces. After migration, it should verify:
|
builds. Open it read-only with Bun SQLite and copy its `project`, `session`, and `session_message` rows directly into the
|
||||||
|
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.
|
||||||
|
|
||||||
- Preserved rows and encoded values remain unchanged.
|
The previous V2 import is part of this migration and uses the same completion marker. It needs no source-specific cursor:
|
||||||
- Todo rows remain available in the unchanged `todo` table.
|
the destination Session row is the per-Session idempotency boundary, so a retry skips transactions that already committed.
|
||||||
- `event` is empty, and stale pre-launch rows are absent from the rebuilt projections.
|
|
||||||
- Backfilled `session_message` rows represent the canonical V1 `message` and `part` history.
|
Store V1 backfill state in `kv`; do not retain a dedicated `data_migration` table. Store the last successfully migrated
|
||||||
- Each migrated session's `event_sequence` watermark matches its maximum backfilled message sequence.
|
session ID under `migration.v1-v2.session.cursor` and write `migration.v1-v2.completed` with value `true` after every
|
||||||
- Dropped tables no longer exist.
|
session finishes. Delete the cursor key on completion and return immediately on later calls when the completion key
|
||||||
- New tables exist and are empty.
|
exists.
|
||||||
- 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.
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ 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"),
|
||||||
@@ -145,22 +151,33 @@ 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.Struct({
|
const OpenAIChatUsage = Schema.StructWithRest(
|
||||||
prompt_tokens: Schema.optional(Schema.Number),
|
Schema.Struct({
|
||||||
completion_tokens: Schema.optional(Schema.Number),
|
prompt_tokens: optionalNull(Schema.Number),
|
||||||
total_tokens: Schema.optional(Schema.Number),
|
completion_tokens: optionalNull(Schema.Number),
|
||||||
prompt_tokens_details: optionalNull(
|
total_tokens: optionalNull(Schema.Number),
|
||||||
Schema.Struct({
|
prompt_tokens_details: optionalNull(
|
||||||
cached_tokens: Schema.optional(Schema.Number),
|
Schema.StructWithRest(
|
||||||
cache_write_tokens: Schema.optional(Schema.Number),
|
Schema.Struct({
|
||||||
}),
|
cached_tokens: optionalNull(Schema.Number),
|
||||||
),
|
cache_write_tokens: optionalNull(Schema.Number),
|
||||||
completion_tokens_details: optionalNull(
|
}),
|
||||||
Schema.Struct({
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
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),
|
||||||
@@ -168,7 +185,7 @@ const OpenAIChatToolCallDeltaFunction = Schema.Struct({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const OpenAIChatToolCallDelta = Schema.Struct({
|
const OpenAIChatToolCallDelta = Schema.Struct({
|
||||||
index: Schema.Number,
|
index: optionalNull(Schema.Number),
|
||||||
id: optionalNull(Schema.String),
|
id: optionalNull(Schema.String),
|
||||||
function: optionalNull(OpenAIChatToolCallDeltaFunction),
|
function: optionalNull(OpenAIChatToolCallDeltaFunction),
|
||||||
})
|
})
|
||||||
@@ -222,6 +239,8 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -559,22 +578,34 @@ 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 cached = usage.prompt_tokens_details?.cached_tokens
|
const input = usage.prompt_tokens ?? undefined
|
||||||
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens
|
const output = usage.completion_tokens ?? undefined
|
||||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens
|
const cached = usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||||
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, ProviderShared.sumTokens(cached, cacheWrite))
|
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
|
||||||
|
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: usage.prompt_tokens,
|
inputTokens: input,
|
||||||
outputTokens: usage.completion_tokens,
|
outputTokens: output,
|
||||||
nonCachedInputTokens: nonCached,
|
nonCachedInputTokens: nonCached,
|
||||||
cacheReadInputTokens: cached,
|
cacheReadInputTokens: cached,
|
||||||
cacheWriteInputTokens: cacheWrite,
|
cacheWriteInputTokens: cacheWrite,
|
||||||
reasoningTokens: reasoning,
|
reasoningTokens: reasoning,
|
||||||
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
|
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
|
||||||
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,
|
||||||
@@ -657,17 +688,34 @@ 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 finishReason = choice?.finish_reason
|
const rawFinishReason = choice?.finish_reason
|
||||||
? { normalized: mapFinishReason(choice.finish_reason), raw: choice.native_finish_reason ?? choice.finish_reason }
|
const finishReason =
|
||||||
: state.finishReason
|
rawFinishReason !== undefined && rawFinishReason !== null
|
||||||
|
? { 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)
|
||||||
@@ -694,24 +742,37 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const tool of toolDeltas) {
|
// Compatible providers may omit indexes. Prefer durable identity, then use
|
||||||
const current = tools[tool.index]
|
// batch position for parallel deltas or the latest call for sparse chunks.
|
||||||
const pending = pendingTools[tool.index]
|
for (const [position, tool] of toolDeltas.entries()) {
|
||||||
|
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, [tool.index]: { id: id || undefined, name: name || undefined, input: text } }
|
pendingTools = {
|
||||||
|
...pendingTools,
|
||||||
|
[index]: { id: id || undefined, name: name || undefined, input: text },
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (pending) {
|
if (pending) {
|
||||||
pendingTools = { ...pendingTools }
|
pendingTools = { ...pendingTools }
|
||||||
delete pendingTools[tool.index]
|
delete pendingTools[index]
|
||||||
}
|
}
|
||||||
const result = ToolStream.appendOrStart(
|
const result = ToolStream.appendOrStart(
|
||||||
ADAPTER,
|
ADAPTER,
|
||||||
tools,
|
tools,
|
||||||
tool.index,
|
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",
|
||||||
)
|
)
|
||||||
@@ -743,6 +804,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
reasoningDetails: state.reasoningDetails,
|
reasoningDetails: state.reasoningDetails,
|
||||||
reasoningDetailsObserved,
|
reasoningDetailsObserved,
|
||||||
reasoningEmitted,
|
reasoningEmitted,
|
||||||
|
latestToolIndex,
|
||||||
|
nextToolIndex,
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
] as const
|
] as const
|
||||||
@@ -799,6 +862,7 @@ export const protocol = Protocol.make({
|
|||||||
reasoningDetails: [],
|
reasoningDetails: [],
|
||||||
reasoningDetailsObserved: false,
|
reasoningDetailsObserved: false,
|
||||||
reasoningEmitted: false,
|
reasoningEmitted: false,
|
||||||
|
nextToolIndex: 0,
|
||||||
}),
|
}),
|
||||||
step,
|
step,
|
||||||
onHalt: finishEvents,
|
onHalt: finishEvents,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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")
|
||||||
@@ -19,7 +20,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 useCompletionUrls?: boolean
|
readonly useDeploymentBasedUrls?: boolean
|
||||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
export type Config = LanguageModelOptions
|
export type Config = LanguageModelOptions
|
||||||
@@ -29,27 +30,22 @@ 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/v1`
|
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
|
||||||
|
|
||||||
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]
|
||||||
@@ -59,7 +55,7 @@ const defaults = (input: Config) => {
|
|||||||
apiKey: _,
|
apiKey: _,
|
||||||
apiVersion: _apiVersion,
|
apiVersion: _apiVersion,
|
||||||
resourceName: _resourceName,
|
resourceName: _resourceName,
|
||||||
useCompletionUrls: _useCompletionUrls,
|
useDeploymentBasedUrls: _useDeploymentBasedUrls,
|
||||||
baseURL: _baseURL,
|
baseURL: _baseURL,
|
||||||
queryParams: _queryParams,
|
queryParams: _queryParams,
|
||||||
...rest
|
...rest
|
||||||
@@ -80,37 +76,39 @@ const auth = (input: Config) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) =>
|
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config, modelID: string | ModelID) =>
|
||||||
route.with({
|
route.with({
|
||||||
auth: auth(input),
|
auth: auth(input),
|
||||||
endpoint: {
|
endpoint: endpoint(input, modelID),
|
||||||
// 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) =>
|
||||||
configuredResponsesRoute
|
configuredRoute(responsesRoute, input, modelID)
|
||||||
.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) =>
|
||||||
configuredChatRoute
|
configuredRoute(chatRoute, input, modelID)
|
||||||
.with(withOpenAIOptions(modelID, modelDefaults))
|
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||||
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
model: (modelID: string | ModelID) => (input.useCompletionUrls === true ? chat(modelID) : responses(modelID)),
|
model: responses,
|
||||||
responses,
|
responses,
|
||||||
chat,
|
chat,
|
||||||
configure,
|
configure,
|
||||||
@@ -131,6 +129,7 @@ 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,8 +15,7 @@ export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
|
|||||||
|
|
||||||
const VERSION = "vertex-2023-10-16" as const
|
const VERSION = "vertex-2023-10-16" as const
|
||||||
|
|
||||||
// models.dev uses this provider id even though the API contract is Anthropic Messages.
|
export const id = ProviderID.make("google-vertex")
|
||||||
export const id = ProviderID.make("google-vertex-anthropic")
|
|
||||||
|
|
||||||
export type Config = RouteDefaultsInput &
|
export type Config = RouteDefaultsInput &
|
||||||
GoogleVertexShared.OAuthOptions & {
|
GoogleVertexShared.OAuthOptions & {
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ 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 {
|
||||||
|
|||||||
@@ -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" },
|
compatibility: { toolSchema: "gemini", requireFinishReason: false },
|
||||||
})
|
})
|
||||||
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" })
|
expect(updated.compatibility).toEqual({ toolSchema: "gemini", requireFinishReason: false })
|
||||||
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")
|
||||||
|
|||||||
@@ -209,6 +209,27 @@ 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,13 +56,14 @@ 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: GoogleVertexMessages.configure({
|
model,
|
||||||
accessToken: "vertex-token",
|
|
||||||
location: "eu",
|
|
||||||
project: "vertex-project",
|
|
||||||
}).model("claude-sonnet-4-6"),
|
|
||||||
prompt: "Say hello.",
|
prompt: "Say hello.",
|
||||||
}),
|
}),
|
||||||
).pipe(
|
).pipe(
|
||||||
@@ -97,6 +98,7 @@ 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 } from "../lib/http"
|
import { dynamicResponse, fixedResponse } 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,4 +253,106 @@ 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")
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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 { SessionV1Info, SessionStatus } from "@opencode-ai/client/promise"
|
import type { SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||||
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
|
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
|
||||||
import { expect, type Page } from "@playwright/test"
|
import { expect, type Page } from "@playwright/test"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
@@ -18,7 +18,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 = SessionV1Info
|
type Session = SessionInfo
|
||||||
type GlobalEvent = {
|
type GlobalEvent = {
|
||||||
directory: string
|
directory: string
|
||||||
project?: string
|
project?: string
|
||||||
@@ -530,11 +530,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,
|
||||||
directory,
|
location: { directory },
|
||||||
title,
|
title,
|
||||||
version: "dev",
|
cost: 0,
|
||||||
|
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,7 +177,8 @@ 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}`,
|
||||||
|
|||||||
+8
-10
@@ -1,19 +1,18 @@
|
|||||||
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_legacy_new_session"
|
const draftID = "draft_removed_layout_preference"
|
||||||
const directory = "C:/OpenCode/LegacyNewSession"
|
const directory = "C:/OpenCode/RemovedLayoutPreference"
|
||||||
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("redirects a draft to the legacy new-session route", async ({ page }) => {
|
test("ignores persisted old layout preferences when opening drafts", async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: "proj_legacy_new_session",
|
id: "proj_removed_layout_preference",
|
||||||
worktree: directory,
|
worktree: directory,
|
||||||
vcs: "git",
|
vcs: "git",
|
||||||
name: "legacy-new-session",
|
name: "removed-layout-preference",
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
sandboxes: [],
|
sandboxes: [],
|
||||||
},
|
},
|
||||||
@@ -24,7 +23,6 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
|||||||
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 }]),
|
||||||
@@ -35,7 +33,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
|||||||
|
|
||||||
await page.goto(`/new-session?draftId=${draftID}`)
|
await page.goto(`/new-session?draftId=${draftID}`)
|
||||||
|
|
||||||
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
|
await expect(page).toHaveURL(`/new-session?draftId=${draftID}`)
|
||||||
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
|
await expect(page.locator("body")).toHaveAttribute("data-new-layout", "")
|
||||||
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
await expect(page.getByRole("textbox", { name: "Prompt" })).toBeVisible()
|
||||||
})
|
})
|
||||||
@@ -263,7 +263,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
|
|||||||
),
|
),
|
||||||
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
|
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
|
||||||
{
|
{
|
||||||
id: "prt_0104_text",
|
id: followingTextID,
|
||||||
sessionID,
|
sessionID,
|
||||||
messageID: assistantID,
|
messageID: assistantID,
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|||||||
@@ -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 class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep">
|
<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">
|
||||||
<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,6 +40,7 @@
|
|||||||
"@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:",
|
||||||
|
|||||||
+46
-161
@@ -14,14 +14,11 @@ 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,
|
||||||
@@ -43,7 +40,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, useServerSync } from "@/context/server-sync"
|
import { ServerSyncProvider } 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"
|
||||||
@@ -54,58 +51,36 @@ 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, useSettings } from "@/context/settings"
|
import { SettingsProvider } from "@/context/settings"
|
||||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
import { SDKProvider } from "@/context/sdk"
|
||||||
import { WslServersProvider } from "@/wsl/context"
|
import { WslServersProvider } from "@/wsl/context"
|
||||||
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||||
import LegacyLayout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import NewLayout from "@/pages/layout-new"
|
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
import { TargetSessionRouteContent } from "@/pages/session"
|
||||||
import { NewHome } from "@/pages/home"
|
import { Home } 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 SessionRoute = () => {
|
const DirectoryDraftRedirect = () => {
|
||||||
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 (!settings.general.newLayoutDesigns()) return
|
if (search.draftId || !tabs.ready()) return
|
||||||
if (params.id || search.draftId) return
|
const directory = decode64(params.dir)
|
||||||
if (!tabs.ready() || !sdk().directory) return
|
if (!directory) return
|
||||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
tabs.newDraft({ server: server.key, directory }, search.prompt)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return null
|
||||||
<SessionRouteErrorBoundary sessionID={params.id}>
|
|
||||||
<SessionPage />
|
|
||||||
</SessionRouteErrorBoundary>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TargetServerRoute(props: ParentProps) {
|
function TargetServerRoute(props: ParentProps) {
|
||||||
@@ -117,9 +92,7 @@ function TargetServerRoute(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Owns the server-identity remount. Session changes must NOT remount this
|
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||||
// 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>
|
||||||
@@ -134,35 +107,6 @@ 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) {
|
||||||
@@ -175,17 +119,8 @@ 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()}>
|
||||||
@@ -194,14 +129,7 @@ function DraftRoute() {
|
|||||||
keyed
|
keyed
|
||||||
fallback={<Navigate href="/" />}
|
fallback={<Navigate href="/" />}
|
||||||
>
|
>
|
||||||
{(draft) => (
|
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||||
<Show
|
|
||||||
when={settings.general.newLayoutDesigns()}
|
|
||||||
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
|
|
||||||
>
|
|
||||||
<ResolvedDraftRoute draft={draft} />
|
|
||||||
</Show>
|
|
||||||
)}
|
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
@@ -237,10 +165,6 @@ 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) {
|
|
||||||
return <>{props.children}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: {
|
__OPENCODE__?: {
|
||||||
@@ -267,17 +191,11 @@ 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)
|
||||||
const enabled = settings.general.newLayoutDesigns()
|
document.body.classList.remove("text-12-regular")
|
||||||
document.body.toggleAttribute("data-new-layout", enabled)
|
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
|
||||||
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
|
||||||
@@ -320,7 +238,6 @@ 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
|
||||||
@@ -335,19 +252,11 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
function AppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||||
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}>
|
||||||
<NewLayout>{props.children}</NewLayout>
|
<Layout>{props.children}</Layout>
|
||||||
</ServerScopedProviders>
|
</ServerScopedProviders>
|
||||||
</SelectedServerProviders>
|
</SelectedServerProviders>
|
||||||
)
|
)
|
||||||
@@ -536,7 +445,7 @@ export function AppInterface(props: {
|
|||||||
startup?: Promise<void>
|
startup?: Promise<void>
|
||||||
serverScoped?: JSX.Element
|
serverScoped?: JSX.Element
|
||||||
}) {
|
}) {
|
||||||
// The visual new layout lives in the router root so it remains mounted across
|
// The visual 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) => (
|
||||||
@@ -557,26 +466,22 @@ export function AppInterface(props: {
|
|||||||
<GlobalProvider>
|
<GlobalProvider>
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
||||||
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
<Dynamic
|
||||||
<Dynamic
|
component={props.router ?? Router}
|
||||||
component={props.router ?? Router}
|
root={(routerProps) => (
|
||||||
root={(routerProps) => (
|
<TabsProvider>
|
||||||
<TabsProvider>
|
<PermissionProvider>
|
||||||
<PermissionProvider>
|
<NotificationProvider>
|
||||||
<NotificationProvider>
|
<ServerShell>
|
||||||
<ServerShell>
|
<AppLayout serverScoped={props.serverScoped}>{routerProps.children}</AppLayout>
|
||||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
</ServerShell>
|
||||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
</NotificationProvider>
|
||||||
</Show>
|
</PermissionProvider>
|
||||||
</ServerShell>
|
</TabsProvider>
|
||||||
</NotificationProvider>
|
)}
|
||||||
</PermissionProvider>
|
>
|
||||||
</TabsProvider>
|
<Routes />
|
||||||
)}
|
</Dynamic>
|
||||||
>
|
|
||||||
<Routes serverScoped={props.serverScoped} />
|
|
||||||
</Dynamic>
|
|
||||||
</Show>
|
|
||||||
</ConnectionGate>
|
</ConnectionGate>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
</GlobalProvider>
|
</GlobalProvider>
|
||||||
@@ -584,40 +489,20 @@ export function AppInterface(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Routes(props: { serverScoped?: JSX.Element }) {
|
function Routes() {
|
||||||
const settings = useSettings()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Route
|
<Route path="/" component={Home} />
|
||||||
component={(routeProps) => (
|
<Route path="/:dir" component={DirectoryDraftRedirect} />
|
||||||
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
|
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
|
||||||
)}
|
<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 NewLayoutLegacySessionRedirect() {
|
function LegacySessionRedirect() {
|
||||||
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.
|
Before Width: | Height: | Size: 187 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 163 KiB |
@@ -14,7 +14,6 @@ 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
|
||||||
@@ -257,7 +256,6 @@ 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 =
|
||||||
@@ -266,9 +264,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.directory),
|
description: project ? displayName(project) : getFilename(session.location.directory),
|
||||||
category: props.category(),
|
category: props.category(),
|
||||||
directory: session.directory,
|
directory: session.location.directory,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
server: props.server,
|
server: props.server,
|
||||||
project,
|
project,
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
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.supported || model.save.isPending}>
|
|
||||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ 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
|
||||||
|
|
||||||
@@ -57,7 +56,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
}
|
}
|
||||||
const error = () => {
|
const error = () => {
|
||||||
const s = mcpStatus()
|
const s = mcpStatus()
|
||||||
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
|
if (s?.status === "failed") return s.error
|
||||||
}
|
}
|
||||||
const enabled = () => status() === "connected"
|
const enabled = () => status() === "connected"
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,21 +6,17 @@ 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 { useMutation } from "@tanstack/solid-query"
|
import { Show } from "solid-js"
|
||||||
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 { usePlatform } from "@/context/platform"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
|
||||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useTabs } from "@/context/tabs"
|
import {
|
||||||
|
type ServerDomainController,
|
||||||
const DEFAULT_USERNAME = "opencode"
|
type ServerFormController,
|
||||||
|
useServerDomainController,
|
||||||
|
useServerFormController,
|
||||||
|
} from "@/components/server/server-management-controller"
|
||||||
|
|
||||||
interface ServerFormProps {
|
interface ServerFormProps {
|
||||||
value: string
|
value: string
|
||||||
@@ -39,76 +35,6 @@ 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) => {
|
||||||
@@ -176,385 +102,40 @@ function ServerForm(props: ServerFormProps) {
|
|||||||
|
|
||||||
export function DialogSelectServer() {
|
export function DialogSelectServer() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const controller = useServerManagementController({ onSelect: dialog.close })
|
const language = useLanguage()
|
||||||
|
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={controller.formTitle()}>
|
<Dialog title={title()}>
|
||||||
<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 when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
|
<Show
|
||||||
<ServerConnectionForm controller={controller} />
|
when={form.state.open()}
|
||||||
|
fallback={<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />}
|
||||||
|
>
|
||||||
|
<ServerConnectionForm form={form} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
export function ServerConnectionList(props: {
|
||||||
const navigate = useNavigate()
|
domain: ServerDomainController
|
||||||
const server = useServer()
|
onAdd: () => void
|
||||||
const tabs = useTabs()
|
onEdit: (server: ServerConnection.Http) => void
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (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 = raw
|
|
||||||
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()
|
||||||
|
|
||||||
@@ -568,10 +149,10 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
}}
|
}}
|
||||||
noInitialSelection
|
noInitialSelection
|
||||||
emptyMessage={language.t("dialog.server.empty")}
|
emptyMessage={language.t("dialog.server.empty")}
|
||||||
items={props.controller.sortedItems}
|
items={props.domain.collection.items}
|
||||||
key={(x) => x.http.url}
|
key={(x) => x.http.url}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x)
|
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
|
||||||
}}
|
}}
|
||||||
divider={true}
|
divider={true}
|
||||||
>
|
>
|
||||||
@@ -580,15 +161,15 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
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.controller.status()[key]} />
|
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
||||||
</div>
|
</div>
|
||||||
<ServerRow
|
<ServerRow
|
||||||
conn={i}
|
conn={i}
|
||||||
dimmed={props.controller.status()[key]?.healthy === false}
|
dimmed={props.domain.collection.health()[key]?.healthy === false}
|
||||||
status={props.controller.status()[key]}
|
status={props.domain.collection.health()[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.controller.defaultKey() === ServerConnection.key(i)}>
|
<Show when={props.domain.defaults.key() === 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>
|
||||||
@@ -597,7 +178,12 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
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 when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
|
<Show
|
||||||
|
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>
|
||||||
|
|
||||||
@@ -616,27 +202,27 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (i.type !== "http") return
|
if (i.type !== "http") return
|
||||||
props.controller.startEdit(i)
|
props.onEdit(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.controller.canDefault() && props.controller.defaultKey() !== key}>
|
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
||||||
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
|
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(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.controller.canDefault() && props.controller.defaultKey() === key}>
|
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||||
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}>
|
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(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.controller.canRemove(key)}>
|
<Show when={props.domain.connection.canRemove(key)}>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
|
onSelect={() => props.domain.connection.remove(key)}
|
||||||
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>
|
||||||
@@ -657,7 +243,7 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon="plus-small"
|
icon="plus-small"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={props.controller.startAdd}
|
onClick={props.onAdd}
|
||||||
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")}
|
||||||
@@ -667,38 +253,38 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
export function ServerConnectionForm(props: { form: ServerFormController }) {
|
||||||
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.controller.formValue()}
|
value={props.form.state.value()}
|
||||||
name={props.controller.formName()}
|
name={props.form.state.name()}
|
||||||
username={props.controller.formUsername()}
|
username={props.form.state.username()}
|
||||||
password={props.controller.formPassword()}
|
password={props.form.state.password()}
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
busy={props.controller.formBusy()}
|
busy={props.form.state.busy()}
|
||||||
error={props.controller.formError()}
|
error={props.form.state.error()}
|
||||||
status={props.controller.formStatus()}
|
status={props.form.state.status()}
|
||||||
onChange={props.controller.handleFormChange()}
|
onChange={props.form.change.value}
|
||||||
onNameChange={props.controller.handleFormNameChange()}
|
onNameChange={props.form.change.name}
|
||||||
onUsernameChange={props.controller.handleFormUsernameChange()}
|
onUsernameChange={props.form.change.username}
|
||||||
onPasswordChange={props.controller.handleFormPasswordChange()}
|
onPasswordChange={props.form.change.password}
|
||||||
onSubmit={props.controller.submitForm}
|
onSubmit={props.form.submit}
|
||||||
onBack={props.controller.resetForm}
|
onBack={props.form.reset}
|
||||||
/>
|
/>
|
||||||
<div class="shrink-0 pb-5">
|
<div class="shrink-0 pb-5">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={props.controller.submitForm}
|
onClick={props.form.submit}
|
||||||
disabled={props.controller.formBusy()}
|
disabled={props.form.state.busy()}
|
||||||
class="px-3 py-1.5"
|
class="px-3 py-1.5"
|
||||||
>
|
>
|
||||||
{props.controller.formBusy()
|
{props.form.state.busy()
|
||||||
? language.t("dialog.server.add.checking")
|
? language.t("dialog.server.add.checking")
|
||||||
: props.controller.isAddMode()
|
: props.form.state.adding()
|
||||||
? language.t("dialog.server.add.button")
|
? language.t("dialog.server.add.button")
|
||||||
: language.t("common.save")}
|
: language.t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
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 "@/types"
|
import type { ReferenceInfo } from "@opencode-ai/client/promise"
|
||||||
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 "@/types"
|
import type { ReferenceInfo } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
export { createPromptInputHistory }
|
export { createPromptInputHistory }
|
||||||
export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
|
export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import type { AgentPartInput, FilePartInput, Part, TextPartInput } from "@/types"
|
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } 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 = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
|
type PromptRequestPart =
|
||||||
|
| (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
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Message, Session } from "@/types"
|
import type { Message } 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"
|
||||||
@@ -21,7 +22,6 @@ 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"
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
@@ -310,10 +310,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seed = (dir: string, info: Session) => {
|
const seed = (dir: string, info: SessionInfo) => {
|
||||||
serverSync().session.remember(info)
|
serverSync().session.remember(info)
|
||||||
const [, setStore] = serverSync().child(dir)
|
const [, setStore] = serverSync().child(dir)
|
||||||
setStore("session", (list: Session[]) => {
|
setStore("session", (list: SessionInfo[]) => {
|
||||||
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) {
|
||||||
@@ -407,7 +407,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
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"),
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
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>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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 { useServerManagementController } from "@/components/dialog-select-server"
|
import type { ServerActionsController } from "@/components/server/server-management-controller"
|
||||||
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
|
||||||
controller: ReturnType<typeof useServerManagementController>
|
domain: ServerActionsController
|
||||||
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.controller.canDefault()}
|
canDefault={props.domain.defaults.available()}
|
||||||
isDefault={props.controller.defaultKey() === key}
|
isDefault={props.domain.defaults.key() === key}
|
||||||
canRemove={props.controller.canRemove(key)}
|
canRemove={props.domain.connection.canRemove(key)}
|
||||||
onEdit={props.onEdit}
|
onEdit={props.onEdit}
|
||||||
onSetDefault={() => props.controller.setDefault(key)}
|
onSetDefault={() => props.domain.defaults.set(key)}
|
||||||
onRemoveDefault={() => props.controller.setDefault(null)}
|
onRemoveDefault={() => props.domain.defaults.set(null)}
|
||||||
onRemove={() => props.controller.handleRemove(key)}
|
onRemove={() => props.domain.connection.remove(key)}
|
||||||
open={props.open}
|
open={props.open}
|
||||||
onOpenChange={props.onOpenChange}
|
onOpenChange={props.onOpenChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,793 +0,0 @@
|
|||||||
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 { 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 [shells] = createResource(
|
|
||||||
async () => {
|
|
||||||
// TODO: Restore executable shell discovery; V2 shell.list only lists shell processes.
|
|
||||||
return [] 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>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.shell.title")}
|
|
||||||
description={language.t("settings.general.row.shell.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-shell"
|
|
||||||
disabled
|
|
||||||
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
|
|
||||||
// TODO: Restore config writes when the V2 client exposes a config API.
|
|
||||||
// void serverSync().updateConfig({ shell: option.value })
|
|
||||||
}}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
triggerStyle={{ "min-width": "180px" }}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
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 { 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 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" && !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) => {
|
|
||||||
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) => {
|
|
||||||
await serverSDK()
|
|
||||||
.api.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().api.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={false}>
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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 { useServerManagementController } from "../dialog-select-server"
|
import { useServerFormController } from "../server/server-management-controller"
|
||||||
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 controller = useServerManagementController({
|
const form = useServerFormController({
|
||||||
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") controller.startAdd()
|
if (props.mode === "add") form.start.add()
|
||||||
if (props.mode === "edit" && props.server) controller.startEdit(props.server)
|
if (props.mode === "edit" && props.server) form.start.edit(props.server)
|
||||||
setOpened(true)
|
setOpened(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
controller.resetForm()
|
form.reset()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!opened()) return
|
if (!opened()) return
|
||||||
if (controller.isFormMode()) return
|
if (form.state.open()) 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()
|
||||||
controller.submitForm()
|
form.submit()
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (controller.formBusy()) return language.t("dialog.server.add.checking")
|
if (form.state.busy()) 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={controller.formValue()}
|
value={form.state.value()}
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
invalid={!!controller.formError()}
|
invalid={!!form.state.error()}
|
||||||
disabled={controller.formBusy()}
|
disabled={form.state.busy()}
|
||||||
autofocus
|
autofocus
|
||||||
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
|
onInput={(event) => form.change.value(event.currentTarget.value)}
|
||||||
onKeyDown={keyDown}
|
onKeyDown={keyDown}
|
||||||
/>
|
/>
|
||||||
<Show when={controller.formError()}>
|
<Show when={form.state.error()}>
|
||||||
<span class="settings-v2-server-dialog-error">{controller.formError()}</span>
|
<span class="settings-v2-server-dialog-error">{form.state.error()}</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={controller.formName()}
|
value={form.state.name()}
|
||||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||||
disabled={controller.formBusy()}
|
disabled={form.state.busy()}
|
||||||
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
|
onInput={(event) => form.change.name(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={controller.formUsername()}
|
value={form.state.username()}
|
||||||
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||||
disabled={controller.formBusy()}
|
disabled={form.state.busy()}
|
||||||
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
|
onInput={(event) => form.change.username(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={controller.formPassword()}
|
value={form.state.password()}
|
||||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||||
disabled={controller.formBusy()}
|
disabled={form.state.busy()}
|
||||||
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
|
onInput={(event) => form.change.password(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={controller.formBusy()} onClick={() => dialog.close()}>
|
<ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
|
<ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
|
||||||
{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)?.directory
|
if (route.type === "session") return serverSync().session.get(route.sessionId)?.location.directory
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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"
|
||||||
@@ -27,7 +26,6 @@ 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 = {
|
||||||
@@ -87,7 +85,6 @@ 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 mobile = createMediaQuery("(max-width: 767px)")
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
@@ -96,7 +93,7 @@ export const SettingsGeneralV2: Component<{
|
|||||||
|
|
||||||
const dir = createMemo(() => {
|
const dir = createMemo(() => {
|
||||||
if (!props.sessionID) return undefined
|
if (!props.sessionID) return undefined
|
||||||
return serverSync().session.lineage.peek(props.sessionID)?.session.directory
|
return serverSync().session.lineage.peek(props.sessionID)?.session.location.directory
|
||||||
})
|
})
|
||||||
const accepting = createMemo(() => {
|
const accepting = createMemo(() => {
|
||||||
const value = dir()
|
const value = dir()
|
||||||
@@ -225,31 +222,6 @@ 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>
|
||||||
@@ -689,14 +661,6 @@ 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 />
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
// @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>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 { useServerManagementController } from "../dialog-select-server"
|
import { useServerCollectionController } from "../server/server-management-controller"
|
||||||
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 controller = useServerManagementController()
|
const domain = useServerCollectionController()
|
||||||
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(
|
||||||
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
() => domain.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
const filtered = createMemo(() => {
|
const filtered = createMemo(() => {
|
||||||
const items = controller.sortedItems().filter((item) => !isWslServer(item))
|
const items = domain.collection.items().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 = () => {
|
||||||
dialog.push(() => <DialogServerV2 mode="add" />)
|
void dialog.push(() => <DialogServerV2 mode="add" />)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEdit = (server: ServerConnection.Http) => {
|
const openEdit = (server: ServerConnection.Http) => {
|
||||||
dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
void dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<WslServerSettings controller={controller} servers={wslServers} />
|
<WslServerSettings domain={domain} servers={wslServers} />
|
||||||
<For each={filtered()}>
|
<For each={filtered()}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const key = ServerConnection.key(item)
|
const key = ServerConnection.key(item)
|
||||||
const health = () => controller.status()[key]
|
const health = () => domain.collection.health()[key]
|
||||||
const isDefault = () => controller.defaultKey() === key
|
const isDefault = () => domain.defaults.key() === 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={controller.canDefault() && isDefault()}>
|
<Show when={domain.defaults.available() && isDefault()}>
|
||||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
|
<ServerRowMenu server={item} domain={domain} onEdit={openEdit} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -426,8 +426,7 @@ 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":
|
"bg-icon-warning-base": status() === "needs_auth",
|
||||||
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">
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ 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)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -48,7 +47,6 @@ 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" || status === "needs_client_registration")
|
return input.mcp.some((status) => status === "needs_auth")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasNonBlockingServiceIssue(input: {
|
export function hasNonBlockingServiceIssue(input: {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ 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 { Session } from "@/types"
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
|
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"
|
||||||
@@ -20,7 +21,7 @@ export function TabNavItem(props: {
|
|||||||
ref?: Ref<HTMLDivElement>
|
ref?: Ref<HTMLDivElement>
|
||||||
href: string
|
href: string
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
session: () => Session | undefined
|
session: () => SessionInfo | undefined
|
||||||
fallbackTitle?: string
|
fallbackTitle?: string
|
||||||
onRename: (title: string) => Promise<void>
|
onRename: (title: string) => Promise<void>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -54,18 +55,21 @@ export function TabNavItem(props: {
|
|||||||
if (!session) return
|
if (!session) return
|
||||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||||
})
|
})
|
||||||
const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
|
const title = createMemo(() => {
|
||||||
|
const session = props.session()
|
||||||
|
return session ? sessionLabel(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.directory })
|
return displayName(project() ?? { worktree: session.location.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.directory.replace(home, "~") : session.directory
|
return home ? session.location.directory.replace(home, "~") : session.location.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(() => {
|
||||||
@@ -231,7 +235,7 @@ export function TabNavItem(props: {
|
|||||||
{(session) => (
|
{(session) => (
|
||||||
<SessionTabAvatar
|
<SessionTabAvatar
|
||||||
project={project()}
|
project={project()}
|
||||||
directory={session().directory}
|
directory={session().location.directory}
|
||||||
sessionId={session().id}
|
sessionId={session().id}
|
||||||
server={props.server}
|
server={props.server}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 { Session } from "@/types"
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
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: () => Session | undefined
|
session: () => SessionInfo | undefined
|
||||||
fallbackTitle?: string
|
fallbackTitle?: string
|
||||||
onRename: (title: string) => Promise<void>
|
onRename: (title: string) => Promise<void>
|
||||||
onNavigate: (element: HTMLDivElement) => void
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
@@ -127,7 +127,7 @@ function SessionTabEntry(props: {
|
|||||||
createRoot((dispose) => {
|
createRoot((dispose) => {
|
||||||
try {
|
try {
|
||||||
void ctx.sync
|
void ctx.sync
|
||||||
.ensureDirSyncContext(value.directory)
|
.ensureDirSyncContext(value.location.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.directory),
|
dir: base64Encode(value.location.directory),
|
||||||
id: value.id,
|
id: value.id,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ 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
|
||||||
@@ -194,7 +193,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
({ route, sdk }) =>
|
({ route, sdk }) =>
|
||||||
sdk.api.session
|
sdk.api.session
|
||||||
.get({ sessionID: route.sessionId })
|
.get({ sessionID: route.sessionId })
|
||||||
.then(normalizeSessionInfo)
|
|
||||||
.catch(() => {}),
|
.catch(() => {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -256,7 +254,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.directory }, "", model)
|
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -248,11 +248,6 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
|
|||||||
return IS_MAC ? parts.join("") : parts.join("+")
|
return IS_MAC ? parts.join("") : parts.join("+")
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeybindV2 takes an array instead of a string
|
|
||||||
export function formatKeybindKeys(config: string, t?: (key: KeyLabel) => string): string[] {
|
|
||||||
return formatKeybindParts(config, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEditableTarget(target: EventTarget | null) {
|
function isEditableTarget(target: EventTarget | null) {
|
||||||
if (!(target instanceof HTMLElement)) return false
|
if (!(target instanceof HTMLElement)) return false
|
||||||
if (target.isContentEditable) return true
|
if (target.isContentEditable) return true
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import type { Message, Part, Session } from "@/types"
|
import type { Message, Part } 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([
|
||||||
@@ -46,7 +46,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.directory !== directory) return
|
if (!session || session.location.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 +74,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: Session) {
|
remember(session: SessionInfo) {
|
||||||
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?.directory === directory) return session
|
if (session?.location.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[] }) {
|
||||||
@@ -125,7 +125,6 @@ export const createDirSyncContext = (
|
|||||||
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.api.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)
|
||||||
|
|||||||
@@ -286,13 +286,6 @@ 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,12 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
Config,
|
Config,
|
||||||
Path,
|
Path,
|
||||||
PermissionRequest,
|
|
||||||
Project,
|
Project,
|
||||||
ProviderAuthResponse,
|
ProviderAuthResponse,
|
||||||
QuestionRequest,
|
|
||||||
ReferenceInfo,
|
|
||||||
Session,
|
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
import type {
|
import type {
|
||||||
AgentListInput,
|
AgentListInput,
|
||||||
@@ -17,12 +13,16 @@ import type {
|
|||||||
CommandListOutput,
|
CommandListOutput,
|
||||||
LocationGetInput,
|
LocationGetInput,
|
||||||
LocationGetOutput,
|
LocationGetOutput,
|
||||||
|
PermissionRequest,
|
||||||
ProjectCurrentInput,
|
ProjectCurrentInput,
|
||||||
ProjectCurrentOutput,
|
ProjectCurrentOutput,
|
||||||
ProjectListOutput,
|
ProjectListOutput,
|
||||||
ReferenceListInput,
|
ReferenceListInput,
|
||||||
ReferenceListOutput,
|
ReferenceListOutput,
|
||||||
|
ReferenceInfo,
|
||||||
|
QuestionRequest,
|
||||||
SessionApi,
|
SessionApi,
|
||||||
|
SessionInfo,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
@@ -34,7 +34,6 @@ import type { ServerSession } from "../server-session"
|
|||||||
import {
|
import {
|
||||||
cmp,
|
cmp,
|
||||||
normalizeAgentList,
|
normalizeAgentList,
|
||||||
normalizePermissionRequest,
|
|
||||||
normalizeProjectInfo,
|
normalizeProjectInfo,
|
||||||
normalizeProviderList,
|
normalizeProviderList,
|
||||||
} from "./utils"
|
} from "./utils"
|
||||||
@@ -43,7 +42,6 @@ import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
|||||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
import { normalizeSessionInfo } from "@/utils/session"
|
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
@@ -183,7 +181,7 @@ function projectID(directory: string, projects: Project[]) {
|
|||||||
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
|
function mergeSession(setStore: SetStoreFunction<State>, session: SessionInfo) {
|
||||||
setStore("session", (list) => {
|
setStore("session", (list) => {
|
||||||
const next = list.slice()
|
const next = list.slice()
|
||||||
const idx = next.findIndex((item) => item.id >= session.id)
|
const idx = next.findIndex((item) => item.id >= session.id)
|
||||||
@@ -208,9 +206,7 @@ function warmSessions(input: {
|
|||||||
if (ids.length === 0) return Promise.resolve()
|
if (ids.length === 0) return Promise.resolve()
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
ids.map((sessionID) =>
|
ids.map((sessionID) =>
|
||||||
retry(() => input.api.get({ sessionID })).then((session) =>
|
retry(() => input.api.get({ sessionID })).then((session) => mergeSession(input.setStore, session)),
|
||||||
mergeSession(input.setStore, normalizeSessionInfo(session)),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
).then(() => undefined)
|
).then(() => undefined)
|
||||||
}
|
}
|
||||||
@@ -368,7 +364,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
retry(() =>
|
retry(() =>
|
||||||
input.api.permission.request
|
input.api.permission.request
|
||||||
.list({ location: { directory: input.directory } })
|
.list({ location: { directory: input.directory } })
|
||||||
.then((result) => result.data.map(normalizePermissionRequest))
|
.then((result) => result.data)
|
||||||
.then((permissions) => {
|
.then((permissions) => {
|
||||||
const ids = permissions.map((permission) => permission.sessionID)
|
const ids = permissions.map((permission) => permission.sessionID)
|
||||||
const grouped = groupBySession(
|
const grouped = groupBySession(
|
||||||
@@ -382,7 +378,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
const current = input.session?.data.permission ?? input.store.permission
|
const current = input.session?.data.permission ?? input.store.permission
|
||||||
for (const sessionID of Object.keys(current)) {
|
for (const sessionID of Object.keys(current)) {
|
||||||
if (grouped[sessionID]) continue
|
if (grouped[sessionID]) continue
|
||||||
if (input.session?.get(sessionID)?.directory !== input.directory) continue
|
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||||
if (input.session) input.session.set("permission", sessionID, [])
|
if (input.session) input.session.set("permission", sessionID, [])
|
||||||
if (!input.session) input.setStore("permission", sessionID, [])
|
if (!input.session) input.setStore("permission", sessionID, [])
|
||||||
}
|
}
|
||||||
@@ -416,7 +412,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
const current = input.session?.data.question ?? input.store.question
|
const current = input.session?.data.question ?? input.store.question
|
||||||
for (const sessionID of Object.keys(current)) {
|
for (const sessionID of Object.keys(current)) {
|
||||||
if (grouped[sessionID]) continue
|
if (grouped[sessionID]) continue
|
||||||
if (input.session?.get(sessionID)?.directory !== input.directory) continue
|
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||||
if (input.session) input.session.set("question", sessionID, [])
|
if (input.session) input.session.set("question", sessionID, [])
|
||||||
if (!input.session) input.setStore("question", sessionID, [])
|
if (!input.session) input.setStore("question", sessionID, [])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@/types"
|
import type { Message, Part, Project } from "@/types"
|
||||||
|
import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { State } from "./types"
|
import type { State } from "./types"
|
||||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
||||||
@@ -13,7 +14,7 @@ const rootSession = (input: { id: string; parentID?: string; archived?: number }
|
|||||||
updated: 1,
|
updated: 1,
|
||||||
archived: input.archived,
|
archived: input.archived,
|
||||||
},
|
},
|
||||||
}) as Session
|
}) as SessionInfo
|
||||||
|
|
||||||
const userMessage = (id: string, sessionID: string) =>
|
const userMessage = (id: string, sessionID: string) =>
|
||||||
({
|
({
|
||||||
@@ -38,10 +39,10 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
|
|||||||
({
|
({
|
||||||
id,
|
id,
|
||||||
sessionID,
|
sessionID,
|
||||||
permission: title,
|
action: title,
|
||||||
patterns: ["*"],
|
resources: ["*"],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
always: [],
|
save: [],
|
||||||
}) as PermissionRequest
|
}) as PermissionRequest
|
||||||
|
|
||||||
const questionRequest = (id: string, sessionID: string, title = id) =>
|
const questionRequest = (id: string, sessionID: string, title = id) =>
|
||||||
@@ -512,7 +513,7 @@ describe("applyDirectoryEvent", () => {
|
|||||||
directory: "/tmp",
|
directory: "/tmp",
|
||||||
loadLsp() {},
|
loadLsp() {},
|
||||||
})
|
})
|
||||||
expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.permission).toBe("updated")
|
expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.action).toBe("updated")
|
||||||
|
|
||||||
applyDirectoryEvent({
|
applyDirectoryEvent({
|
||||||
event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } },
|
event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } },
|
||||||
|
|||||||
@@ -3,14 +3,10 @@ import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/
|
|||||||
import type {
|
import type {
|
||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
PermissionRequest,
|
|
||||||
Project,
|
Project,
|
||||||
QuestionRequest,
|
|
||||||
Session,
|
|
||||||
SessionStatus,
|
|
||||||
Todo,
|
Todo,
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
import { dropSessionCaches } from "./session-cache"
|
import { dropSessionCaches } from "./session-cache"
|
||||||
@@ -79,7 +75,7 @@ function cleanupSessionCaches(
|
|||||||
export function cleanupDroppedSessionCaches(
|
export function cleanupDroppedSessionCaches(
|
||||||
store: Store<State>,
|
store: Store<State>,
|
||||||
setStore: SetStoreFunction<State>,
|
setStore: SetStoreFunction<State>,
|
||||||
next: Session[],
|
next: SessionInfo[],
|
||||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||||
) {
|
) {
|
||||||
const keep = new Set(next.map((item) => item.id))
|
const keep = new Set(next.map((item) => item.id))
|
||||||
@@ -128,7 +124,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "session.created": {
|
case "session.created": {
|
||||||
const info = (event.properties as { info: Session }).info
|
const info = (event.properties as { info: SessionInfo }).info
|
||||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
input.setStore("session", result.index, reconcile(info))
|
input.setStore("session", result.index, reconcile(info))
|
||||||
@@ -143,7 +139,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.updated": {
|
case "session.updated": {
|
||||||
const info = (event.properties as { info: Session }).info
|
const info = (event.properties as { info: SessionInfo }).info
|
||||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||||
if (info.time.archived) {
|
if (info.time.archived) {
|
||||||
if (!result.found) break
|
if (!result.found) break
|
||||||
@@ -171,7 +167,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.deleted": {
|
case "session.deleted": {
|
||||||
const properties = event.properties as { sessionID?: string; info?: Session }
|
const properties = event.properties as { sessionID?: string; info?: SessionInfo }
|
||||||
const sessionID = properties.info?.id ?? properties.sessionID
|
const sessionID = properties.info?.id ?? properties.sessionID
|
||||||
if (!sessionID) break
|
if (!sessionID) break
|
||||||
const result = Binary.search(input.store.session, sessionID, (s) => s.id)
|
const result = Binary.search(input.store.session, sessionID, (s) => s.id)
|
||||||
@@ -201,7 +197,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.usage.updated": {
|
case "session.usage.updated": {
|
||||||
const properties = event.properties as Pick<Session, "cost" | "tokens"> & { sessionID: string }
|
const properties = event.properties as Pick<SessionInfo, "cost" | "tokens"> & { sessionID: string }
|
||||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||||
if (!result.found) break
|
if (!result.found) break
|
||||||
input.setStore("session", result.index, (session) => ({
|
input.setStore("session", result.index, (session) => ({
|
||||||
@@ -237,9 +233,8 @@ export function applyDirectoryEvent(input: {
|
|||||||
input.setStore("session", result.index, (session) => ({
|
input.setStore("session", result.index, (session) => ({
|
||||||
...session,
|
...session,
|
||||||
projectID: properties.projectID ?? session.projectID,
|
projectID: properties.projectID ?? session.projectID,
|
||||||
workspaceID: properties.location.workspaceID,
|
location: properties.location,
|
||||||
directory: properties.location.directory,
|
subpath: properties.subpath,
|
||||||
path: properties.subpath,
|
|
||||||
time: { ...session.time, updated: Date.now() },
|
time: { ...session.time, updated: Date.now() },
|
||||||
}))
|
}))
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { SessionV2Info } from "@/types"
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import {
|
import {
|
||||||
applyHomeSessionEvent,
|
applyHomeSessionEvent,
|
||||||
appendHomeSessionEvent,
|
appendHomeSessionEvent,
|
||||||
@@ -48,9 +48,7 @@ describe("Home V2 session index", () => {
|
|||||||
calls.push({ input, signal: options.signal })
|
calls.push({ input, signal: options.signal })
|
||||||
if (!("cursor" in input)) {
|
if (!("cursor" in input)) {
|
||||||
return {
|
return {
|
||||||
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
|
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session({ id: `page-1-${index}` })),
|
||||||
session({ id: `page-1-${index}` }),
|
|
||||||
),
|
|
||||||
cursor: { next: "next-page" },
|
cursor: { next: "next-page" },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +72,7 @@ describe("Home V2 session index", () => {
|
|||||||
const activeNull = {
|
const activeNull = {
|
||||||
...session({ id: "active-null", updated: 20 }),
|
...session({ id: "active-null", updated: 20 }),
|
||||||
time: { created: 1, updated: 20, archived: null },
|
time: { created: 1, updated: 20, archived: null },
|
||||||
} as unknown as SessionV2Info
|
} as unknown as SessionInfo
|
||||||
const result = parseHomeSessionIndex([
|
const result = parseHomeSessionIndex([
|
||||||
session({ id: "root", updated: 30 }),
|
session({ id: "root", updated: 30 }),
|
||||||
activeNull,
|
activeNull,
|
||||||
@@ -83,11 +81,9 @@ describe("Home V2 session index", () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "root",
|
id: "root",
|
||||||
slug: "root",
|
location: { directory: "/project" },
|
||||||
version: "",
|
|
||||||
directory: "/project",
|
|
||||||
projectID: "project",
|
projectID: "project",
|
||||||
title: "root",
|
title: "root",
|
||||||
time: { created: 1, updated: 30 },
|
time: { created: 1, updated: 30 },
|
||||||
@@ -103,17 +99,17 @@ describe("Home V2 session index", () => {
|
|||||||
const now = 10 * 60 * 60 * 1000
|
const now = 10 * 60 * 60 * 1000
|
||||||
const sessions = Array.from({ length: 80 }, (_, index) => ({
|
const sessions = Array.from({ length: 80 }, (_, index) => ({
|
||||||
...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
|
...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
|
||||||
directory: index % 2 === 0 ? "/one" : "/two",
|
location: { directory: index % 2 === 0 ? "/one" : "/two" },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const retained = retainHomeSessions(sessions, 10, now)
|
const retained = retainHomeSessions(sessions, 10, now)
|
||||||
expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
|
expect(retained.filter((item) => item.location.directory === "/one")).toHaveLength(10)
|
||||||
expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
|
expect(retained.filter((item) => item.location.directory === "/two")).toHaveLength(10)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("replays session events over the loaded index", () => {
|
test("replays session events over the loaded index", () => {
|
||||||
const initial = parseHomeSessionIndex([session({ id: "old" })])
|
const initial = parseHomeSessionIndex([session({ id: "old" })])
|
||||||
const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
|
const created = { ...initial[0], id: "new", title: "new", time: { created: 2, updated: 2 } }
|
||||||
|
|
||||||
const afterCreate = applyHomeSessionEvent(initial, {
|
const afterCreate = applyHomeSessionEvent(initial, {
|
||||||
type: "session.created",
|
type: "session.created",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@/types"
|
import type { Event } from "@/types"
|
||||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
import type { SessionInfo, SessionsResponse } from "@opencode-ai/client/promise"
|
||||||
import type { QueryClient } from "@tanstack/solid-query"
|
import type { QueryClient } from "@tanstack/solid-query"
|
||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
@@ -8,14 +8,14 @@ export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
|
|||||||
|
|
||||||
export type HomeSessionEvent = {
|
export type HomeSessionEvent = {
|
||||||
type: "session.created" | "session.updated" | "session.deleted"
|
type: "session.created" | "session.updated" | "session.deleted"
|
||||||
properties: { sessionID: string; info: Session }
|
properties: { sessionID: string; info?: SessionInfo }
|
||||||
}
|
}
|
||||||
export type HomeSessionEvents = {
|
export type HomeSessionEvents = {
|
||||||
sequence: number
|
sequence: number
|
||||||
entries: Array<{ sequence: number; event: HomeSessionEvent }>
|
entries: Array<{ sequence: number; event: HomeSessionEvent }>
|
||||||
}
|
}
|
||||||
export type HomeSessionIndex = {
|
export type HomeSessionIndex = {
|
||||||
sessions: Session[]
|
sessions: SessionInfo[]
|
||||||
eventSequence: number
|
eventSequence: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,11 +26,11 @@ export async function loadHomeSessionIndex(
|
|||||||
list: (
|
list: (
|
||||||
input: { limit: number; order: "desc"; cursor?: string },
|
input: { limit: number; order: "desc"; cursor?: string },
|
||||||
options: { signal?: AbortSignal },
|
options: { signal?: AbortSignal },
|
||||||
) => Promise<V2SessionListResponse>,
|
) => Promise<SessionsResponse>,
|
||||||
eventSequence = 0,
|
eventSequence = 0,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
const data: SessionV2Info[] = []
|
const data: SessionInfo[] = []
|
||||||
let cursor: string | undefined
|
let cursor: string | undefined
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -125,23 +125,28 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st
|
|||||||
// current V2 API orders by creation time and cannot filter roots, archives, or
|
// current V2 API orders by creation time and cannot filter roots, archives, or
|
||||||
// multiple directories. A bounded page could omit an old session updated today.
|
// multiple directories. A bounded page could omit an old session updated today.
|
||||||
// Once released, use client.v2.project.list() and client.v2.session.list({
|
// Once released, use client.v2.project.list() and client.v2.session.list({
|
||||||
// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
|
// parentID: null, order: "desc" }) and replace this full-table scan.
|
||||||
export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
|
export function parseHomeSessionIndex(sessions: SessionInfo[]): SessionInfo[] {
|
||||||
return sessions.flatMap((item) => {
|
return sessions.flatMap((item) => {
|
||||||
if (item.parentID || typeof item.time.archived === "number") return []
|
if (item.parentID || typeof item.time.archived === "number") return []
|
||||||
return [toLegacySummary(item)]
|
return [item]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
|
export function retainHomeSessions(sessions: SessionInfo[], limit: number, now: number) {
|
||||||
const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
|
const grouped = Map.groupBy(sessions, (session) => pathKey(session.location.directory))
|
||||||
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
|
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
|
export function applyHomeSessionEvent(sessions: SessionInfo[], event: HomeSessionEvent) {
|
||||||
const info = event.properties.info
|
const info = event.properties.info
|
||||||
const index = sessions.findIndex((session) => session.id === info.id)
|
const index = sessions.findIndex((session) => session.id === (info?.id ?? event.properties.sessionID))
|
||||||
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
|
if (event.type === "session.deleted") {
|
||||||
|
if (index === -1) return sessions
|
||||||
|
return sessions.toSpliced(index, 1)
|
||||||
|
}
|
||||||
|
if (!info) return sessions
|
||||||
|
if (info.parentID || typeof info.time.archived === "number") {
|
||||||
if (index === -1) return sessions
|
if (index === -1) return sessions
|
||||||
return sessions.toSpliced(index, 1)
|
return sessions.toSpliced(index, 1)
|
||||||
}
|
}
|
||||||
@@ -149,22 +154,3 @@ export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEve
|
|||||||
if (index === -1) return [...sessions, info]
|
if (index === -1) return [...sessions, info]
|
||||||
return sessions.with(index, info)
|
return sessions.with(index, info)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLegacySummary(session: SessionV2Info): Session {
|
|
||||||
return {
|
|
||||||
id: session.id,
|
|
||||||
slug: session.id,
|
|
||||||
projectID: session.projectID,
|
|
||||||
workspaceID: session.location.workspaceID,
|
|
||||||
directory: session.location.directory,
|
|
||||||
path: session.subpath,
|
|
||||||
parentID: session.parentID,
|
|
||||||
cost: session.cost,
|
|
||||||
tokens: session.tokens,
|
|
||||||
title: withTimestampedFallback(session),
|
|
||||||
agent: session.agent,
|
|
||||||
model: session.model,
|
|
||||||
version: "",
|
|
||||||
time: session.time,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export async function toggleMcp(input: {
|
|||||||
needs_auth: input.authenticate,
|
needs_auth: input.authenticate,
|
||||||
disabled: input.connect,
|
disabled: input.connect,
|
||||||
failed: input.connect,
|
failed: input.connect,
|
||||||
needs_client_registration: input.connect,
|
|
||||||
}[input.status]()
|
}[input.status]()
|
||||||
await input.refresh()
|
await input.refresh()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types"
|
import type { Message, Part, Todo } from "@/types"
|
||||||
|
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types"
|
import type { Message, Part, Todo } from "@/types"
|
||||||
|
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { SessionApi } from "@opencode-ai/client/promise"
|
import type { SessionApi } from "@opencode-ai/client/promise"
|
||||||
import { normalizeSessionInfo } from "@/utils/session"
|
|
||||||
|
|
||||||
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
|
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
|
||||||
const result = await input.api.list({
|
const result = await input.api.list({
|
||||||
@@ -9,7 +8,7 @@ export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; d
|
|||||||
order: "desc",
|
order: "desc",
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
data: result.data.map(normalizeSessionInfo),
|
data: result.data,
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
limited: true,
|
limited: true,
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { PermissionRequest, Session } from "@/types"
|
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
|
|
||||||
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
|
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
|
||||||
@@ -11,7 +11,7 @@ const session = (input: { id: string; parentID?: string; created: number; update
|
|||||||
updated: input.updated,
|
updated: input.updated,
|
||||||
archived: input.archived,
|
archived: input.archived,
|
||||||
},
|
},
|
||||||
}) as Session
|
}) as SessionInfo
|
||||||
|
|
||||||
describe("trimSessions", () => {
|
describe("trimSessions", () => {
|
||||||
test("keeps base roots and recent roots beyond the limit", () => {
|
test("keeps base roots and recent roots beyond the limit", () => {
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import type { PermissionRequest, Session } from "@/types"
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
|
import type { PermissionRequest } from "@opencode-ai/client/promise"
|
||||||
import { cmp } from "./utils"
|
import { cmp } from "./utils"
|
||||||
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
|
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
|
||||||
|
|
||||||
export function sessionUpdatedAt(session: Session) {
|
export function sessionUpdatedAt(session: SessionInfo) {
|
||||||
return session.time.updated ?? session.time.created
|
return session.time.updated ?? session.time.created
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compareSessionRecent(a: Session, b: Session) {
|
export function compareSessionRecent(a: SessionInfo, b: SessionInfo) {
|
||||||
const aUpdated = sessionUpdatedAt(a)
|
const aUpdated = sessionUpdatedAt(a)
|
||||||
const bUpdated = sessionUpdatedAt(b)
|
const bUpdated = sessionUpdatedAt(b)
|
||||||
if (aUpdated !== bUpdated) return bUpdated - aUpdated
|
if (aUpdated !== bUpdated) return bUpdated - aUpdated
|
||||||
return cmp(a.id, b.id)
|
return cmp(a.id, b.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
|
export function takeRecentSessions(sessions: SessionInfo[], limit: number, cutoff: number) {
|
||||||
if (limit <= 0) return [] as Session[]
|
if (limit <= 0) return [] as SessionInfo[]
|
||||||
const selected: Session[] = []
|
const selected: SessionInfo[] = []
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
if (!session?.id) continue
|
if (!session?.id) continue
|
||||||
@@ -31,7 +32,7 @@ export function takeRecentSessions(sessions: Session[], limit: number, cutoff: n
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function trimSessions(
|
export function trimSessions(
|
||||||
input: Session[],
|
input: SessionInfo[],
|
||||||
options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
|
options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
|
||||||
) {
|
) {
|
||||||
const limit = Math.max(0, options.limit)
|
const limit = Math.max(0, options.limit)
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ import type {
|
|||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
Path,
|
Path,
|
||||||
PermissionRequest,
|
|
||||||
QuestionRequest,
|
|
||||||
ReferenceInfo,
|
|
||||||
Session,
|
|
||||||
SessionStatus,
|
|
||||||
Todo,
|
Todo,
|
||||||
VcsInfo,
|
VcsInfo,
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
import type {
|
||||||
|
FileDiffInfo,
|
||||||
|
PermissionRequest,
|
||||||
|
QuestionRequest,
|
||||||
|
ReferenceInfo,
|
||||||
|
SessionInfo,
|
||||||
|
SessionStatus,
|
||||||
|
} from "@opencode-ai/client/promise"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
|
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
@@ -42,7 +44,7 @@ export type State = {
|
|||||||
provider: NormalizedProviderListResponse
|
provider: NormalizedProviderListResponse
|
||||||
config: Config
|
config: Config
|
||||||
path: Path
|
path: Path
|
||||||
session: Session[]
|
session: SessionInfo[]
|
||||||
sessionTotal: number
|
sessionTotal: number
|
||||||
session_status: {
|
session_status: {
|
||||||
[sessionID: string]: SessionStatus
|
[sessionID: string]: SessionStatus
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
ModelListOutput,
|
ModelListOutput,
|
||||||
ProviderListOutput,
|
ProviderListOutput,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
|
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||||
|
|
||||||
describe("normalizeAgentList", () => {
|
describe("normalizeAgentList", () => {
|
||||||
test("adapts current agents to the app agent shape", () => {
|
test("adapts current agents to the app agent shape", () => {
|
||||||
@@ -43,30 +43,6 @@ describe("normalizeAgentList", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("normalizePermissionRequest", () => {
|
|
||||||
test("adapts the current permission request to app state", () => {
|
|
||||||
expect(
|
|
||||||
normalizePermissionRequest({
|
|
||||||
id: "permission-1",
|
|
||||||
sessionID: "session-1",
|
|
||||||
action: "read",
|
|
||||||
resources: ["README.md"],
|
|
||||||
save: ["*.md"],
|
|
||||||
metadata: { path: "README.md" },
|
|
||||||
source: { type: "tool", messageID: "message-1", id: "call-1" },
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
id: "permission-1",
|
|
||||||
sessionID: "session-1",
|
|
||||||
permission: "read",
|
|
||||||
patterns: ["README.md"],
|
|
||||||
always: ["*.md"],
|
|
||||||
metadata: { path: "README.md" },
|
|
||||||
tool: { messageID: "message-1", callID: "call-1" },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("normalizeProviderList", () => {
|
describe("normalizeProviderList", () => {
|
||||||
test("groups current models into the app provider catalog", () => {
|
test("groups current models into the app provider catalog", () => {
|
||||||
const result = normalizeProviderList(
|
const result = normalizeProviderList(
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import type {
|
|||||||
AgentListOutput,
|
AgentListOutput,
|
||||||
ModelDefaultOutput,
|
ModelDefaultOutput,
|
||||||
ModelListOutput,
|
ModelListOutput,
|
||||||
PermissionRequest,
|
|
||||||
ProviderListOutput,
|
ProviderListOutput,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import type { Agent, Event, Project, Provider, ProviderListResponse } from "@/types"
|
import type { Agent, Project, Provider, ProviderListResponse } from "@/types"
|
||||||
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
|
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
||||||
@@ -36,22 +35,6 @@ export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Ag
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
type LegacyPermissionRequest = Extract<Event, { type: "permission.asked" }>["properties"]
|
|
||||||
|
|
||||||
export function normalizePermissionRequest(input: PermissionRequest | LegacyPermissionRequest): LegacyPermissionRequest {
|
|
||||||
if ("permission" in input) return input
|
|
||||||
return {
|
|
||||||
id: input.id,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
permission: input.action,
|
|
||||||
patterns: input.resources,
|
|
||||||
always: input.save ?? [],
|
|
||||||
metadata: input.metadata ?? {},
|
|
||||||
tool:
|
|
||||||
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.id } : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeProviderList(
|
export function normalizeProviderList(
|
||||||
providers: ProviderListOutput["data"] | ProviderListResponse,
|
providers: ProviderListOutput["data"] | ProviderListResponse,
|
||||||
models?: ModelListOutput["data"],
|
models?: ModelListOutput["data"],
|
||||||
@@ -140,18 +123,6 @@ export function normalizeProviderList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeProject(project: Project) {
|
|
||||||
if (!project.icon?.url && !project.icon?.override) return project
|
|
||||||
return {
|
|
||||||
...project,
|
|
||||||
icon: {
|
|
||||||
...project.icon,
|
|
||||||
url: undefined,
|
|
||||||
override: undefined,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
|
|||||||
@@ -744,9 +744,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
mobileSidebar: {
|
mobileSidebar: {
|
||||||
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
|
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
|
||||||
show() {
|
|
||||||
setStore("mobileSidebar", "opened", true)
|
|
||||||
},
|
|
||||||
hide() {
|
hide() {
|
||||||
setStore("mobileSidebar", "opened", false)
|
setStore("mobileSidebar", "opened", false)
|
||||||
},
|
},
|
||||||
@@ -952,33 +949,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
if (current.reviewOpen.includes(path)) return
|
if (current.reviewOpen.includes(path)) return
|
||||||
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
|
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
|
||||||
},
|
},
|
||||||
closePath(path: string) {
|
|
||||||
const session = key()
|
|
||||||
const current = store.sessionView[session]?.reviewOpen
|
|
||||||
if (!current) return
|
|
||||||
|
|
||||||
const index = current.indexOf(path)
|
|
||||||
if (index === -1) return
|
|
||||||
setStore(
|
|
||||||
"sessionView",
|
|
||||||
session,
|
|
||||||
"reviewOpen",
|
|
||||||
produce((draft) => {
|
|
||||||
if (!draft) return
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
togglePath(path: string) {
|
|
||||||
const session = key()
|
|
||||||
const current = store.sessionView[session]?.reviewOpen
|
|
||||||
if (!current || !current.includes(path)) {
|
|
||||||
this.openPath(path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.closePath(path)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ function createServerNotificationState(input: {
|
|||||||
|
|
||||||
const handleSessionError = (
|
const handleSessionError = (
|
||||||
directory: string,
|
directory: string,
|
||||||
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
|
event: { properties: EventSessionError["properties"] },
|
||||||
time: number,
|
time: number,
|
||||||
) => {
|
) => {
|
||||||
const sessionID = event.properties.sessionID
|
const sessionID = event.properties.sessionID
|
||||||
@@ -372,7 +372,7 @@ function createServerNotificationState(input: {
|
|||||||
void playSoundById(settings.sounds.errors())
|
void playSoundById(settings.sounds.errors())
|
||||||
}
|
}
|
||||||
|
|
||||||
const error = "error" in event.properties ? event.properties.error : undefined
|
const error = event.properties.error
|
||||||
append({
|
append({
|
||||||
directory,
|
directory,
|
||||||
time,
|
time,
|
||||||
@@ -393,7 +393,7 @@ function createServerNotificationState(input: {
|
|||||||
|
|
||||||
const unsub = serverSDK().event.listen((e) => {
|
const unsub = serverSDK().event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
if (event.type !== "session.idle" && event.type !== "session.execution.failed") return
|
||||||
|
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const time = Date.now()
|
const time = Date.now()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { PermissionRequest, Session } from "@/types"
|
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
|
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ const session = (input: { id: string; parentID?: string }) =>
|
|||||||
({
|
({
|
||||||
id: input.id,
|
id: input.id,
|
||||||
parentID: input.parentID,
|
parentID: input.parentID,
|
||||||
}) as Session
|
}) as SessionInfo
|
||||||
|
|
||||||
const permission = (sessionID: string) =>
|
const permission = (sessionID: string) =>
|
||||||
({
|
({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { PermissionRequest } from "@/types"
|
import type { PermissionRequest } from "@opencode-ai/client/promise"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { ServerSDK } from "@/context/server-sdk"
|
import type { ServerSDK } from "@/context/server-sdk"
|
||||||
import type { ServerSync } from "./server-sync"
|
import type { ServerSync } from "./server-sync"
|
||||||
@@ -13,7 +13,6 @@ import { type DraftTab, useTabs } from "./tabs"
|
|||||||
import { useSettings } from "./settings"
|
import { useSettings } from "./settings"
|
||||||
import { requireServerKey } from "@/utils/session-route"
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import { normalizePermissionRequest } from "./global-sync/utils"
|
|
||||||
import {
|
import {
|
||||||
acceptKey,
|
acceptKey,
|
||||||
directoryAcceptKey,
|
directoryAcceptKey,
|
||||||
@@ -133,7 +132,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
if (draft) return draft.directory
|
if (draft) return draft.directory
|
||||||
if (!params.id) return
|
if (!params.id) return
|
||||||
if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return
|
if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return
|
||||||
return selected().sync.session.lineage.peek(params.id)?.session.directory
|
return selected().sync.session.lineage.peek(params.id)?.session.location.directory
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -256,9 +255,7 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
|||||||
}
|
}
|
||||||
|
|
||||||
const list = async (directory: string) => {
|
const list = async (directory: string) => {
|
||||||
return input.sdk.api.permission.request
|
return input.sdk.api.permission.request.list({ location: { directory } }).then((result) => result.data)
|
||||||
.list({ location: { directory } })
|
|
||||||
.then((result) => result.data.map(normalizePermissionRequest))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe("resumeStreamAfterPageShow", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("adaptServerEvent", () => {
|
describe("adaptServerEvent", () => {
|
||||||
test("preserves current events while adapting permission requests for existing consumers", () => {
|
test("preserves current permission requests", () => {
|
||||||
const current = {
|
const current = {
|
||||||
id: "evt_1",
|
id: "evt_1",
|
||||||
created: 1,
|
created: 1,
|
||||||
@@ -35,9 +35,9 @@ describe("adaptServerEvent", () => {
|
|||||||
properties: {
|
properties: {
|
||||||
id: "perm_1",
|
id: "perm_1",
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
permission: "read",
|
action: "read",
|
||||||
patterns: ["src/**"],
|
resources: ["src/**"],
|
||||||
tool: { messageID: "msg_1", callID: "call_1" },
|
source: { type: "tool", messageID: "msg_1", id: "call_1" },
|
||||||
},
|
},
|
||||||
current,
|
current,
|
||||||
})
|
})
|
||||||
@@ -87,11 +87,7 @@ describe("current event buffering", () => {
|
|||||||
test("preserves boundaries between distinct delta streams", () => {
|
test("preserves boundaries between distinct delta streams", () => {
|
||||||
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
||||||
|
|
||||||
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual([
|
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
|
||||||
"evt_1",
|
|
||||||
"evt_2",
|
|
||||||
"evt_3",
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves current event order when enqueuing", () => {
|
test("preserves current event order when enqueuing", () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||||
import type { Event, PermissionRequest } from "@/types"
|
import type { Event } from "@/types"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
@@ -24,25 +24,6 @@ type CurrentDelta = Extract<
|
|||||||
>
|
>
|
||||||
|
|
||||||
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||||
if (event.type === "permission.asked") {
|
|
||||||
return {
|
|
||||||
id: event.id,
|
|
||||||
type: "permission.asked",
|
|
||||||
properties: {
|
|
||||||
id: event.data.id,
|
|
||||||
sessionID: event.data.sessionID,
|
|
||||||
permission: event.data.action,
|
|
||||||
patterns: event.data.resources,
|
|
||||||
always: event.data.save ?? [],
|
|
||||||
metadata: event.data.metadata ?? {},
|
|
||||||
tool:
|
|
||||||
event.data.source?.type === "tool"
|
|
||||||
? { messageID: event.data.source.messageID, callID: event.data.source.id }
|
|
||||||
: undefined,
|
|
||||||
} satisfies PermissionRequest,
|
|
||||||
current: event,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,19 +8,19 @@ import type {
|
|||||||
SessionMessageAssistantTool,
|
SessionMessageAssistantTool,
|
||||||
SessionMessageInfo,
|
SessionMessageInfo,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import type { Message, Part, Session } from "@/types"
|
import type { Message, Part } from "@/types"
|
||||||
import { createServerSession } from "./server-session"
|
import { createServerSession } from "./server-session"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
type MessageApi = ServerApi["message"]
|
type MessageApi = ServerApi["message"]
|
||||||
|
|
||||||
const session = (id: string, parentID?: string): Session => ({
|
const session = (id: string, parentID?: string): SessionInfo => ({
|
||||||
id,
|
id,
|
||||||
slug: id,
|
|
||||||
projectID: "project",
|
projectID: "project",
|
||||||
directory: "/repo",
|
location: { directory: "/repo" },
|
||||||
title: id,
|
title: id,
|
||||||
version: "1",
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
parentID,
|
parentID,
|
||||||
time: { created: 1, updated: 1 },
|
time: { created: 1, updated: 1 },
|
||||||
})
|
})
|
||||||
@@ -35,18 +35,8 @@ type MessageResponse = {
|
|||||||
}
|
}
|
||||||
type SingleMessageResponse = { data: MessageResponse["data"][number] }
|
type SingleMessageResponse = { data: MessageResponse["data"][number] }
|
||||||
|
|
||||||
function sessionInfo(value: Session): SessionInfo {
|
function sessionInfo(value: SessionInfo): SessionInfo {
|
||||||
return {
|
return value
|
||||||
id: value.id,
|
|
||||||
parentID: value.parentID,
|
|
||||||
projectID: value.projectID,
|
|
||||||
cost: value.cost ?? 0,
|
|
||||||
tokens: value.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
time: value.time,
|
|
||||||
title: value.title,
|
|
||||||
location: { directory: value.directory, workspaceID: value.workspaceID },
|
|
||||||
subpath: value.path,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentMessages(data: MessageResponse["data"]): SessionMessageInfo[] {
|
function currentMessages(data: MessageResponse["data"]): SessionMessageInfo[] {
|
||||||
@@ -272,7 +262,7 @@ const retryImmediately: typeof retry = async (task, options = {}) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup(sessions: Record<string, Session>) {
|
function setup(sessions: Record<string, SessionInfo>) {
|
||||||
const get: unknown[] = []
|
const get: unknown[] = []
|
||||||
const messages: unknown[] = []
|
const messages: unknown[] = []
|
||||||
const client = {
|
const client = {
|
||||||
@@ -1687,7 +1677,7 @@ describe("server session", () => {
|
|||||||
ctx.store.apply({ type: "session.created", properties: { sessionID: "root", info: session("root") } })
|
ctx.store.apply({ type: "session.created", properties: { sessionID: "root", info: session("root") } })
|
||||||
ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } })
|
ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } })
|
||||||
|
|
||||||
expect(ctx.store.get("root")?.directory).toBe("/repo")
|
expect(ctx.store.get("root")?.location.directory).toBe("/repo")
|
||||||
expect(ctx.store.data.session_working("root")).toBe(true)
|
expect(ctx.store.data.session_working("root")).toBe(true)
|
||||||
expect(ctx.get).toEqual([])
|
expect(ctx.get).toEqual([])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { retry } from "@opencode-ai/core/util/retry"
|
import { retry } from "@opencode-ai/core/util/retry"
|
||||||
import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
|
import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
import type {
|
import type {
|
||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
PermissionRequest,
|
|
||||||
QuestionRequest,
|
|
||||||
Session,
|
|
||||||
SessionStatus,
|
|
||||||
Todo,
|
Todo,
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||||
import { batch } from "solid-js"
|
import { batch } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { rootSession } from "@/utils/session-route"
|
import { rootSession } from "@/utils/session-route"
|
||||||
import { normalizeSessionInfo } from "@/utils/session"
|
|
||||||
import { normalizeSessionMessages } from "@/utils/session-message"
|
import { normalizeSessionMessages } from "@/utils/session-message"
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||||
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
|
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
|
||||||
@@ -192,7 +187,7 @@ export function createServerSession(
|
|||||||
const messageApi = bundled ? api.message : (messageApiOrOptions as MessageApi)
|
const messageApi = bundled ? api.message : (messageApiOrOptions as MessageApi)
|
||||||
const options = bundled ? (messageApiOrOptions as ServerSessionOptions | undefined) : currentOptions
|
const options = bundled ? (messageApiOrOptions as ServerSessionOptions | undefined) : currentOptions
|
||||||
const [data, setData] = createStore({
|
const [data, setData] = createStore({
|
||||||
info: {} as Record<string, Session | undefined>,
|
info: {} as Record<string, SessionInfo | undefined>,
|
||||||
session_status: {} as Record<string, SessionStatus>,
|
session_status: {} as Record<string, SessionStatus>,
|
||||||
session_diff: {} as Record<string, FileDiffInfo[]>,
|
session_diff: {} as Record<string, FileDiffInfo[]>,
|
||||||
todo: {} as Record<string, Todo[]>,
|
todo: {} as Record<string, Todo[]>,
|
||||||
@@ -206,7 +201,7 @@ export function createServerSession(
|
|||||||
return (this.session_status[id]?.type ?? "idle") !== "idle"
|
return (this.session_status[id]?.type ?? "idle") !== "idle"
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const requests = new Map<string, Promise<Session>>()
|
const requests = new Map<string, Promise<SessionInfo>>()
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
const inflightTodo = new Map<string, Promise<void>>()
|
const inflightTodo = new Map<string, Promise<void>>()
|
||||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||||
@@ -255,7 +250,7 @@ export function createServerSession(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const remember = (session: Session) => {
|
const remember = (session: SessionInfo) => {
|
||||||
setData("info", session.id, reconcile(session))
|
setData("info", session.id, reconcile(session))
|
||||||
infoSeen.delete(session.id)
|
infoSeen.delete(session.id)
|
||||||
infoSeen.add(session.id)
|
infoSeen.add(session.id)
|
||||||
@@ -305,7 +300,7 @@ export function createServerSession(
|
|||||||
const pending = requests.get(sessionID)
|
const pending = requests.get(sessionID)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
const active = generation(sessionID)
|
const active = generation(sessionID)
|
||||||
const request = sessionApi.get({ sessionID }).then(normalizeSessionInfo)
|
const request = sessionApi.get({ sessionID })
|
||||||
const resolved = request.then((result) => {
|
const resolved = request.then((result) => {
|
||||||
if (generations.get(sessionID) !== active) return result
|
if (generations.get(sessionID) !== active) return result
|
||||||
return remember(result)
|
return remember(result)
|
||||||
@@ -921,9 +916,8 @@ export function createServerSession(
|
|||||||
remember({
|
remember({
|
||||||
...info,
|
...info,
|
||||||
projectID: event.data.projectID ?? info.projectID,
|
projectID: event.data.projectID ?? info.projectID,
|
||||||
workspaceID: event.data.location.workspaceID,
|
location: event.data.location,
|
||||||
directory: event.data.location.directory,
|
subpath: event.data.subpath,
|
||||||
path: event.data.subpath,
|
|
||||||
time: { ...info.time, updated: event.created },
|
time: { ...info.time, updated: event.created },
|
||||||
})
|
})
|
||||||
if (event.type === "session.usage.updated" && info)
|
if (event.type === "session.usage.updated" && info)
|
||||||
@@ -969,16 +963,17 @@ export function createServerSession(
|
|||||||
}
|
}
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "session.created":
|
case "session.created":
|
||||||
remember((event.properties as { info: Session }).info)
|
if ((event.properties as { info?: SessionInfo }).info)
|
||||||
|
remember((event.properties as { info: SessionInfo }).info)
|
||||||
return
|
return
|
||||||
case "session.updated": {
|
case "session.updated": {
|
||||||
const info = (event.properties as { info: Session }).info
|
const info = (event.properties as { info: SessionInfo }).info
|
||||||
remember(info)
|
remember(info)
|
||||||
if (info.time.archived) evict([info.id])
|
if (info.time.archived) evict([info.id])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "session.deleted": {
|
case "session.deleted": {
|
||||||
const properties = event.properties as { sessionID?: string; info?: Session }
|
const properties = event.properties as { sessionID?: string; info?: SessionInfo }
|
||||||
const sessionID = properties.info?.id ?? properties.sessionID
|
const sessionID = properties.info?.id ?? properties.sessionID
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
infoSeen.delete(sessionID)
|
infoSeen.delete(sessionID)
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ describe("pickDirectoriesToEvict", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("loadRootSessions", () => {
|
describe("loadRootSessions", () => {
|
||||||
test("loads and normalizes a limited page of root sessions", async () => {
|
test("loads a limited page of root sessions", async () => {
|
||||||
const calls: SessionListInput[] = []
|
const calls: SessionListInput[] = []
|
||||||
|
|
||||||
const result = await loadRootSessions({
|
const result = await loadRootSessions({
|
||||||
@@ -137,9 +137,7 @@ describe("loadRootSessions", () => {
|
|||||||
limit: 10,
|
limit: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.data).toEqual([
|
expect(result.data).toEqual([expect.objectContaining({ id: "session-1", location: { directory: "dir" } })])
|
||||||
expect.objectContaining({ id: "session-1", directory: "dir", slug: "session-1", version: "" }),
|
|
||||||
])
|
|
||||||
expect(result.limited).toBe(true)
|
expect(result.limited).toBe(true)
|
||||||
expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }])
|
expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type {
|
|||||||
Path,
|
Path,
|
||||||
Project,
|
Project,
|
||||||
ProviderAuthResponse,
|
ProviderAuthResponse,
|
||||||
SessionStatus,
|
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
@@ -55,6 +54,7 @@ import type {
|
|||||||
McpResourceCatalogOutput,
|
McpResourceCatalogOutput,
|
||||||
McpServer,
|
McpServer,
|
||||||
SessionActiveOutput,
|
SessionActiveOutput,
|
||||||
|
SessionStatus,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import { toggleMcp } from "./global-sync/mcp"
|
import { toggleMcp } from "./global-sync/mcp"
|
||||||
import { createServerSession, type ServerSession } from "./server-session"
|
import { createServerSession, type ServerSession } from "./server-session"
|
||||||
@@ -451,7 +451,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const indexSession = (info: Parameters<typeof session.remember>[0]) => {
|
const indexSession = (info: Parameters<typeof session.remember>[0]) => {
|
||||||
const key = directoryKey(info.directory)
|
const key = directoryKey(info.location.directory)
|
||||||
const existing = children.children[key]
|
const existing = children.children[key]
|
||||||
if (!existing) return
|
if (!existing) return
|
||||||
applyDirectoryEvent({
|
applyDirectoryEvent({
|
||||||
@@ -476,7 +476,24 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
|
|
||||||
if (event.current) session.applyV2(event.current)
|
if (event.current) session.applyV2(event.current)
|
||||||
session.apply(event)
|
session.apply(event)
|
||||||
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
if (event.current?.type === "session.created")
|
||||||
|
void session
|
||||||
|
.resolve(event.current.data.sessionID, { force: true })
|
||||||
|
.then((info) => {
|
||||||
|
if (!session.get(info.id)) return
|
||||||
|
indexSession(info)
|
||||||
|
homeSessions.apply({
|
||||||
|
type: "session.created",
|
||||||
|
properties: { sessionID: info.id, info },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
if (event.current?.type === "session.deleted")
|
||||||
|
homeSessions.apply({
|
||||||
|
type: "session.deleted",
|
||||||
|
properties: { sessionID: event.current.data.sessionID },
|
||||||
|
})
|
||||||
|
if (event.type === "session.created" || event.type === "session.deleted") {
|
||||||
if ("info" in event.properties) homeSessions.apply(event as Parameters<typeof homeSessions.apply>[0])
|
if ("info" in event.properties) homeSessions.apply(event as Parameters<typeof homeSessions.apply>[0])
|
||||||
}
|
}
|
||||||
homeSessions.refresh(event.type)
|
homeSessions.refresh(event.type)
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import {
|
|
||||||
hasExistingWebState,
|
|
||||||
isAppUpgrade,
|
|
||||||
layoutTransitionState,
|
|
||||||
maximumSunsetTimeout,
|
|
||||||
newLayoutDesignsDefault,
|
|
||||||
nextSunsetCheckDelay,
|
|
||||||
resolveNewLayoutDesigns,
|
|
||||||
shouldDisplayTabsToast,
|
|
||||||
shouldEnableNewLayout,
|
|
||||||
} from "./settings"
|
|
||||||
|
|
||||||
describe("layout transition", () => {
|
|
||||||
test("blank profiles default to the new layout", () => {
|
|
||||||
expect(newLayoutDesignsDefault).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("hides the transition until a sunset is scheduled", () => {
|
|
||||||
expect(layoutTransitionState(false, true, false, false)).toEqual({ available: false, notice: false })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("existing profiles can switch before sunset", () => {
|
|
||||||
expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("classifies web profiles from existing settings or a recorded version", () => {
|
|
||||||
expect(hasExistingWebState("{}", undefined)).toBe(true)
|
|
||||||
expect(hasExistingWebState(null, "1.17.19")).toBe(true)
|
|
||||||
expect(hasExistingWebState(null, undefined)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves explicit and default layout preferences", () => {
|
|
||||||
expect(resolveNewLayoutDesigns(false, false, true)).toBe(false)
|
|
||||||
expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false)
|
|
||||||
expect(resolveNewLayoutDesigns(false, undefined, true)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("sunset replaces the toggle with a dismissible notice", () => {
|
|
||||||
expect(layoutTransitionState(true, true, true, false)).toEqual({ available: false, notice: true })
|
|
||||||
expect(layoutTransitionState(true, true, true, true)).toEqual({ available: false, notice: false })
|
|
||||||
expect(resolveNewLayoutDesigns(true, false)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("caps checks for sunsets beyond the browser timeout limit", () => {
|
|
||||||
expect(nextSunsetCheckDelay(maximumSunsetTimeout + 1_000, 0)).toBe(maximumSunsetTimeout)
|
|
||||||
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
|
|
||||||
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("enables the new layout when upgrading from 1.17.19 or earlier", () => {
|
|
||||||
expect(shouldEnableNewLayout("v1.17.19", "1.17.20")).toBe(true)
|
|
||||||
expect(shouldEnableNewLayout("1.16.9", "2.0.0")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("enables the new layout when no previous version was recorded", () => {
|
|
||||||
expect(shouldEnableNewLayout(undefined, "1.17.20")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("detects upgrades only when a previous version is older", () => {
|
|
||||||
expect(isAppUpgrade("1.17.19", "1.17.20")).toBe(true)
|
|
||||||
expect(isAppUpgrade(undefined, "1.17.20")).toBe(false)
|
|
||||||
expect(isAppUpgrade("1.17.20", "1.17.20")).toBe(false)
|
|
||||||
expect(isAppUpgrade("1.17.21", "1.17.20")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("shows the tabs toast for upgrades and existing installs without a recorded version", () => {
|
|
||||||
expect(shouldDisplayTabsToast("1.17.19", "1.17.20", false)).toBe(true)
|
|
||||||
expect(shouldDisplayTabsToast(undefined, "1.17.20", true)).toBe(true)
|
|
||||||
expect(shouldDisplayTabsToast(undefined, "1.17.20", false)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not enable the new layout without a qualifying upgrade", () => {
|
|
||||||
expect(shouldEnableNewLayout("1.17.19", "1.17.19")).toBe(false)
|
|
||||||
expect(shouldEnableNewLayout("1.17.20", "1.17.21")).toBe(false)
|
|
||||||
expect(shouldEnableNewLayout(undefined, "1.17.19")).toBe(false)
|
|
||||||
expect(shouldEnableNewLayout("dev", "1.17.20")).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
import { createEffect, createMemo } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
|
|
||||||
export interface NotificationSettings {
|
export interface NotificationSettings {
|
||||||
agent: boolean
|
agent: boolean
|
||||||
@@ -34,10 +33,6 @@ export interface Settings {
|
|||||||
editToolPartsExpanded: boolean
|
editToolPartsExpanded: boolean
|
||||||
showCustomAgents: boolean
|
showCustomAgents: boolean
|
||||||
mobileTitlebarPosition: "top" | "bottom"
|
mobileTitlebarPosition: "top" | "bottom"
|
||||||
newLayoutDesigns?: boolean
|
|
||||||
layoutTransitionEligible?: boolean
|
|
||||||
newInterfaceNoticeDismissed?: boolean
|
|
||||||
shouldDisplayTabsToast?: boolean
|
|
||||||
}
|
}
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: number
|
fontSize: number
|
||||||
@@ -56,75 +51,6 @@ export interface Settings {
|
|||||||
export const monoDefault = "System Mono"
|
export const monoDefault = "System Mono"
|
||||||
export const sansDefault = "System Sans"
|
export const sansDefault = "System Sans"
|
||||||
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||||
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
|
||||||
export const newLayoutDesignsDefault = true
|
|
||||||
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
|
|
||||||
export const oldInterfaceSunset = new Date(2026, 8, 14)
|
|
||||||
const newLayoutDesignsUpgradeCutoff = "1.17.19"
|
|
||||||
|
|
||||||
function compareVersions(a: string, b: string) {
|
|
||||||
const parse = (version: string) => {
|
|
||||||
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim())
|
|
||||||
if (!match) return
|
|
||||||
return match.slice(1).map(Number)
|
|
||||||
}
|
|
||||||
const left = parse(a)
|
|
||||||
const right = parse(b)
|
|
||||||
if (!left || !right) return
|
|
||||||
const index = left.findIndex((part, index) => part !== right[index])
|
|
||||||
return index === -1 ? 0 : left[index]! - right[index]!
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAppUpgrade(previous: string | undefined, current: string | undefined) {
|
|
||||||
if (!previous || !current) return false
|
|
||||||
const comparison = compareVersions(current, previous)
|
|
||||||
return comparison !== undefined && comparison > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldDisplayTabsToast(
|
|
||||||
previous: string | undefined,
|
|
||||||
current: string | undefined,
|
|
||||||
existingInstall: boolean,
|
|
||||||
) {
|
|
||||||
return isAppUpgrade(previous, current) || (!previous && existingInstall)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasExistingWebState(settings: Promise<string> | string | null, previousVersion: string | undefined) {
|
|
||||||
return settings !== null || previousVersion !== undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
|
|
||||||
if (!current) return false
|
|
||||||
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
|
|
||||||
if (!previous) return currentComparison !== undefined && currentComparison > 0
|
|
||||||
if (!isAppUpgrade(previous, current)) return false
|
|
||||||
const previousComparison = compareVersions(previous, newLayoutDesignsUpgradeCutoff)
|
|
||||||
return (
|
|
||||||
previousComparison !== undefined &&
|
|
||||||
currentComparison !== undefined &&
|
|
||||||
previousComparison <= 0 &&
|
|
||||||
currentComparison > 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
|
|
||||||
return {
|
|
||||||
available: scheduled && eligible && !retired,
|
|
||||||
notice: scheduled && eligible && retired && !dismissed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const maximumSunsetTimeout = 2_147_483_647
|
|
||||||
|
|
||||||
export function nextSunsetCheckDelay(sunset: number, now: number) {
|
|
||||||
return Math.min(Math.max(0, sunset - now), maximumSunsetTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveNewLayoutDesigns(retired: boolean, preference: boolean | undefined, fallback = true) {
|
|
||||||
if (retired) return true
|
|
||||||
return preference ?? fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const monoFallback =
|
const monoFallback =
|
||||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||||
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||||
@@ -223,17 +149,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
name: "Settings",
|
name: "Settings",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const platform = usePlatform()
|
const [store, setStore, , ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||||
const [store, setStore, settingsInit, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
|
||||||
const [launch, setLaunch, , launchReady] = persisted(
|
|
||||||
"app-version.v1",
|
|
||||||
createStore<{ version?: string }>({ version: undefined }),
|
|
||||||
)
|
|
||||||
const [launchState, setLaunchState] = createStore({
|
|
||||||
classified: false,
|
|
||||||
migrationApplied: false,
|
|
||||||
previous: undefined as string | undefined,
|
|
||||||
})
|
|
||||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
||||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
||||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
||||||
@@ -241,93 +157,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
() => store.general?.showCustomAgents,
|
() => store.general?.showCustomAgents,
|
||||||
defaultSettings.general.showCustomAgents,
|
defaultSettings.general.showCustomAgents,
|
||||||
)
|
)
|
||||||
const sunset = oldInterfaceSunset
|
|
||||||
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
|
|
||||||
const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean")
|
|
||||||
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
|
|
||||||
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
|
|
||||||
const layoutUpgrade = createMemo(() =>
|
|
||||||
launchState.classified && !launchState.migrationApplied
|
|
||||||
? shouldEnableNewLayout(launchState.previous, platform.version)
|
|
||||||
: false,
|
|
||||||
)
|
|
||||||
const layoutTransition = createMemo(() =>
|
|
||||||
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
|
|
||||||
)
|
|
||||||
const newLayoutDesigns = createMemo(() => {
|
|
||||||
if (layoutUpgrade()) return true
|
|
||||||
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
|
|
||||||
if (!layoutTransitionClassified()) {
|
|
||||||
return resolveNewLayoutDesigns(
|
|
||||||
oldInterfaceRetired(),
|
|
||||||
store.general?.newLayoutDesigns,
|
|
||||||
legacyNewLayoutDesignsDefault,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return resolveNewLayoutDesigns(
|
|
||||||
oldInterfaceRetired(),
|
|
||||||
store.general?.newLayoutDesigns,
|
|
||||||
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
|
||||||
|
|
||||||
if (sunset && !oldInterfaceRetired()) {
|
|
||||||
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
|
||||||
const checkSunset = () => {
|
|
||||||
if (Date.now() >= sunset.getTime()) {
|
|
||||||
setOldInterfaceRetired(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
timeout.current = setTimeout(checkSunset, nextSunsetCheckDelay(sunset.getTime(), Date.now()))
|
|
||||||
}
|
|
||||||
checkSunset()
|
|
||||||
onCleanup(() => {
|
|
||||||
if (timeout.current !== undefined) clearTimeout(timeout.current)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!launchReady() || launchState.classified) return
|
|
||||||
setLaunchState({
|
|
||||||
classified: true,
|
|
||||||
previous: launch.version,
|
|
||||||
})
|
|
||||||
if (!platform.version || launch.version === platform.version) return
|
|
||||||
setLaunch("version", platform.version)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready() || !launchState.classified || platform.platform !== "web") return
|
|
||||||
if (layoutTransitionClassified()) return
|
|
||||||
setStore("general", "layoutTransitionEligible", hasExistingWebState(settingsInit, launchState.previous))
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready() || !launchState.classified || launchState.migrationApplied) return
|
|
||||||
if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {
|
|
||||||
setStore("general", "newLayoutDesigns", true)
|
|
||||||
}
|
|
||||||
setLaunchState("migrationApplied", true)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready() || !launchState.classified) return
|
|
||||||
if (typeof store.general?.shouldDisplayTabsToast === "boolean") return
|
|
||||||
if (!launchState.previous && !layoutTransitionClassified()) return
|
|
||||||
setStore(
|
|
||||||
"general",
|
|
||||||
"shouldDisplayTabsToast",
|
|
||||||
shouldDisplayTabsToast(launchState.previous, platform.version, layoutTransitionEligible()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready() || !oldInterfaceRetired()) return
|
|
||||||
if (store.general?.newLayoutDesigns === true) return
|
|
||||||
setStore("general", "newLayoutDesigns", true)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
@@ -413,34 +242,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setMobileTitlebarPosition(value: "top" | "bottom") {
|
setMobileTitlebarPosition(value: "top" | "bottom") {
|
||||||
setStore("general", "mobileTitlebarPosition", value)
|
setStore("general", "mobileTitlebarPosition", value)
|
||||||
},
|
},
|
||||||
newLayoutDesigns,
|
newLayoutDesigns: () => true,
|
||||||
setNewLayoutDesigns(value: boolean) {
|
|
||||||
const next = oldInterfaceRetired() ? true : value
|
|
||||||
if (newLayoutDesigns() === next) return
|
|
||||||
setStore("general", "newLayoutDesigns", next)
|
|
||||||
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
|
|
||||||
},
|
|
||||||
layoutTransitionClassified,
|
|
||||||
setOldLayoutEligible(eligible: boolean) {
|
|
||||||
const current = store.general?.layoutTransitionEligible
|
|
||||||
if (typeof current === "boolean") return
|
|
||||||
setStore("general", "layoutTransitionEligible", eligible)
|
|
||||||
},
|
|
||||||
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
|
|
||||||
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
|
|
||||||
dismissNewInterfaceNotice() {
|
|
||||||
setStore("general", "newInterfaceNoticeDismissed", true)
|
|
||||||
},
|
|
||||||
shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false),
|
|
||||||
dismissTabsToast() {
|
|
||||||
setStore("general", "shouldDisplayTabsToast", false)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
visibility: {
|
visibility: {
|
||||||
fileTree: visible(showFileTree),
|
fileTree: showFileTree,
|
||||||
search: visible(showSearch),
|
search: showSearch,
|
||||||
status: visible(showStatus),
|
status: showStatus,
|
||||||
customAgents: visible(showCustomAgents),
|
customAgents: showCustomAgents,
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Session } from "@/types"
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
||||||
@@ -45,7 +45,7 @@ export const tabHref = (tab: Tab) =>
|
|||||||
|
|
||||||
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
|
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
|
||||||
|
|
||||||
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
|
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: SessionInfo) {
|
||||||
return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
|
return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,10 +349,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
for (const key of removed) memory.remove(key)
|
for (const key of removed) memory.remove(key)
|
||||||
for (const key of removed) removeInfo(key)
|
for (const key of removed) removeInfo(key)
|
||||||
},
|
},
|
||||||
rememberSessionInfo(tab: SessionTab, session: Session) {
|
rememberSessionInfo(tab: SessionTab, session: SessionInfo) {
|
||||||
const key = tabKey(tab)
|
const key = tabKey(tab)
|
||||||
const next = { title: session.title, directory: session.directory }
|
const next = { title: session.title, directory: session.location.directory }
|
||||||
const current = info[key]
|
const current = info[key]
|
||||||
|
console.log({ tab, session, current })
|
||||||
if (current?.title === next.title && current.directory === next.directory) return
|
if (current?.title === next.title && current.directory === next.directory) return
|
||||||
setInfo(key, next)
|
setInfo(key, next)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ test("archiving a Home session removes its open titlebar tab", async () => {
|
|||||||
|
|
||||||
await archiveHomeSession({
|
await archiveHomeSession({
|
||||||
server: remote,
|
server: remote,
|
||||||
session: { id: "ses_1", directory: "/workspace" },
|
session: { id: "ses_1", location: { directory: "/workspace" } },
|
||||||
archive: async () => undefined,
|
archive: async () => undefined,
|
||||||
remove: () => {
|
remove: () => {
|
||||||
removed = true
|
removed = true
|
||||||
@@ -36,7 +36,7 @@ test("reports archive failures without removing the session", async () => {
|
|||||||
|
|
||||||
await archiveHomeSession({
|
await archiveHomeSession({
|
||||||
server: remote,
|
server: remote,
|
||||||
session: { id: "ses_1", directory: "/workspace" },
|
session: { id: "ses_1", location: { directory: "/workspace" } },
|
||||||
archive: async () => Promise.reject(failure),
|
archive: async () => Promise.reject(failure),
|
||||||
remove: () => {
|
remove: () => {
|
||||||
removed = true
|
removed = true
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||||
import type { ServerConnection } from "@/context/server"
|
import type { ServerConnection } from "@/context/server"
|
||||||
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
type HomeSession = {
|
type HomeSession = Pick<SessionInfo, "id" | "location">
|
||||||
id: string
|
|
||||||
directory: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function archiveHomeSession(input: {
|
export async function archiveHomeSession(input: {
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
@@ -19,7 +17,7 @@ export async function archiveHomeSession(input: {
|
|||||||
input.remove()
|
input.remove()
|
||||||
notifySessionTabsRemoved({
|
notifySessionTabsRemoved({
|
||||||
server: input.server,
|
server: input.server,
|
||||||
directory: input.session.directory,
|
directory: input.session.location.directory,
|
||||||
sessionIDs: [input.session.id],
|
sessionIDs: [input.session.id],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { createHomeSessionSearchController } from "./home/home-session-search-co
|
|||||||
import { createHomeSessionsController } from "./home/home-sessions-controller"
|
import { createHomeSessionsController } from "./home/home-sessions-controller"
|
||||||
import { HomeSessions } from "./home/home-sessions"
|
import { HomeSessions } from "./home/home-sessions"
|
||||||
|
|
||||||
export function NewHome() {
|
export function Home() {
|
||||||
const home = createHomeController()
|
const home = createHomeController()
|
||||||
const projects = createHomeProjectsController(home)
|
const projects = createHomeProjectsController(home)
|
||||||
const sessions = createHomeSessionsController(home)
|
const sessions = createHomeSessionsController(home)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||||
import { useServerManagementController } from "@/components/dialog-select-server"
|
import { useServerActionsController } from "@/components/server/server-management-controller"
|
||||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||||
import { type LocalProject } from "@/context/layout"
|
import { type LocalProject } from "@/context/layout"
|
||||||
@@ -22,7 +22,7 @@ export function createHomeProjectsController(home: HomeController) {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const openSettings = useSettingsCommand()
|
const openSettings = useSettingsCommand()
|
||||||
const serverManagement = useServerManagementController({ navigateOnAdd: false })
|
const serverManagement = useServerActionsController()
|
||||||
const [_state, setState, _, ready] = persisted(
|
const [_state, setState, _, ready] = persisted(
|
||||||
Persist.global("home.servers", ["home.servers.v1"]),
|
Persist.global("home.servers", ["home.servers.v1"]),
|
||||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||||
@@ -56,12 +56,12 @@ export function createHomeProjectsController(home: HomeController) {
|
|||||||
const key = ServerConnection.key(conn)
|
const key = ServerConnection.key(conn)
|
||||||
setState("collapsed", key, !state().collapsed[key])
|
setState("collapsed", key, !state().collapsed[key])
|
||||||
},
|
},
|
||||||
canDefault: serverManagement.canDefault,
|
canDefault: serverManagement.defaults.available,
|
||||||
defaultKey: serverManagement.defaultKey,
|
defaultKey: serverManagement.defaults.key,
|
||||||
setDefault: (conn: ServerConnection.Any | undefined) =>
|
setDefault: (conn: ServerConnection.Any | undefined) =>
|
||||||
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
|
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
|
||||||
canRemove: (conn: ServerConnection.Any) => serverManagement.canRemove(ServerConnection.key(conn)),
|
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
|
||||||
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
|
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
|
||||||
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
|
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
|
||||||
focus: home.selection.focusServer,
|
focus: home.selection.focusServer,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCommand } from "@/context/command"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { serverName } from "@/context/server"
|
import { serverName } from "@/context/server"
|
||||||
import { displayName } from "@/pages/layout/helpers"
|
import { displayName } from "@/pages/layout/helpers"
|
||||||
|
import { sessionLabel } from "@/utils/session-title"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { createMemo, onCleanup } from "solid-js"
|
import { createMemo, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
@@ -23,7 +24,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
|
|||||||
if (!value) return []
|
if (!value) return []
|
||||||
return sessions.data
|
return sessions.data
|
||||||
.searchRecords()
|
.searchRecords()
|
||||||
.filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
|
.filter((record) => `${sessionLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
|
||||||
})
|
})
|
||||||
const active = createMemo(() => {
|
const active = createMemo(() => {
|
||||||
const records = results()
|
const records = results()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Session } from "@/types"
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useMarked } from "@opencode-ai/ui/context/marked"
|
import { useMarked } from "@opencode-ai/ui/context/marked"
|
||||||
@@ -26,7 +26,7 @@ import type { HomeController } from "./home-controller"
|
|||||||
|
|
||||||
const HOME_SESSION_LIMIT = 64
|
const HOME_SESSION_LIMIT = 64
|
||||||
export type HomeSessionRecord = {
|
export type HomeSessionRecord = {
|
||||||
session: Session
|
session: SessionInfo
|
||||||
project: LocalProject
|
project: LocalProject
|
||||||
projectName: string
|
projectName: string
|
||||||
}
|
}
|
||||||
@@ -180,8 +180,8 @@ export function createHomeSessionsController(home: HomeController) {
|
|||||||
server: () => home.selection.value().server,
|
server: () => home.selection.value().server,
|
||||||
canCreate: () => !!home.project.newSession(),
|
canCreate: () => !!home.project.newSession(),
|
||||||
create: home.project.openNewSession,
|
create: home.project.openNewSession,
|
||||||
open: (session: Session, options?: OpenSessionOptions) => {
|
open: (session: SessionInfo, options?: OpenSessionOptions) => {
|
||||||
const directoryKey = pathKey(session.directory)
|
const directoryKey = pathKey(session.location.directory)
|
||||||
const project =
|
const project =
|
||||||
home.project
|
home.project
|
||||||
.list()
|
.list()
|
||||||
@@ -192,7 +192,7 @@ export function createHomeSessionsController(home: HomeController) {
|
|||||||
) ?? projectForSession(session, home.project.list(), projectByID())
|
) ?? projectForSession(session, home.project.list(), projectByID())
|
||||||
const conn = home.server.focused()
|
const conn = home.server.focused()
|
||||||
if (!conn) return
|
if (!conn) return
|
||||||
const directory = project?.worktree ?? session.directory
|
const directory = project?.worktree ?? session.location.directory
|
||||||
const ctx = home.server.focusedContext()
|
const ctx = home.server.focusedContext()
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
ctx.projects.open(directory)
|
ctx.projects.open(directory)
|
||||||
@@ -206,11 +206,11 @@ export function createHomeSessionsController(home: HomeController) {
|
|||||||
tabs.select(tab)
|
tabs.select(tab)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
archive: async (session: Session) => {
|
archive: async (session: SessionInfo) => {
|
||||||
const conn = home.server.focused()
|
const conn = home.server.focused()
|
||||||
const ctx = home.server.focusedContext()
|
const ctx = home.server.focusedContext()
|
||||||
if (!conn || !ctx) return
|
if (!conn || !ctx) return
|
||||||
const [, setStore] = ctx.sync.child(session.directory)
|
const [, setStore] = ctx.sync.child(session.location.directory)
|
||||||
await archiveHomeSession({
|
await archiveHomeSession({
|
||||||
server: ServerConnection.key(conn),
|
server: ServerConnection.key(conn),
|
||||||
session,
|
session,
|
||||||
@@ -243,17 +243,17 @@ function directories(project: LocalProject) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildHomeSessionRecords(input: {
|
function buildHomeSessionRecords(input: {
|
||||||
sessions: () => Session[]
|
sessions: () => SessionInfo[]
|
||||||
projectDirectories: () => string[]
|
projectDirectories: () => string[]
|
||||||
projects: () => LocalProject[]
|
projects: () => LocalProject[]
|
||||||
projectByID: () => Map<string, LocalProject>
|
projectByID: () => Map<string, LocalProject>
|
||||||
}) {
|
}) {
|
||||||
const directories = new Set(input.projectDirectories().map(pathKey))
|
const directories = new Set(input.projectDirectories().map(pathKey))
|
||||||
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
|
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.location.directory)))
|
||||||
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
|
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
|
||||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||||
.flatMap((session) => {
|
.flatMap((session) => {
|
||||||
const directory = pathKey(session.directory)
|
const directory = pathKey(session.location.directory)
|
||||||
const project =
|
const project =
|
||||||
input
|
input
|
||||||
.projects()
|
.projects()
|
||||||
@@ -267,7 +267,7 @@ function buildHomeSessionRecords(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function homeSessionSearchKey(record: HomeSessionRecord) {
|
export function homeSessionSearchKey(record: HomeSessionRecord) {
|
||||||
return `${pathKey(record.session.directory)}:${record.session.id}`
|
return `${pathKey(record.session.location.directory)}:${record.session.id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
|
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
|
||||||
@@ -304,7 +304,7 @@ export function HomeSessionStatusController(props: {
|
|||||||
}) {
|
}) {
|
||||||
const avatar = useSessionTabAvatarState(
|
const avatar = useSessionTabAvatarState(
|
||||||
props.server,
|
props.server,
|
||||||
() => props.record.session.directory,
|
() => props.record.session.location.directory,
|
||||||
() => props.record.session.id,
|
() => props.record.session.id,
|
||||||
)
|
)
|
||||||
return props.render({
|
return props.render({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Session } from "@/types"
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import { type Accessor, createMemo, For, Show } from "solid-js"
|
import { type Accessor, createMemo, For, Show } from "solid-js"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
@@ -9,7 +9,7 @@ import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
|
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
|
||||||
import { sessionTitle } from "@/utils/session-title"
|
import { sessionLabel } from "@/utils/session-title"
|
||||||
import { shouldOpenSessionInBackground } from "../home-session-open"
|
import { shouldOpenSessionInBackground } from "../home-session-open"
|
||||||
import {
|
import {
|
||||||
HomeSessionStatusController,
|
HomeSessionStatusController,
|
||||||
@@ -53,8 +53,8 @@ export type HomeSessionsViewProps = {
|
|||||||
titleOpacity: (id: HomeSessionGroup["id"]) => number
|
titleOpacity: (id: HomeSessionGroup["id"]) => number
|
||||||
isOpenTab: (record: HomeSessionRecord) => boolean
|
isOpenTab: (record: HomeSessionRecord) => boolean
|
||||||
onCreateSession: () => void
|
onCreateSession: () => void
|
||||||
onOpenSession: (session: Session, options?: OpenSessionOptions) => void
|
onOpenSession: (session: SessionInfo, options?: OpenSessionOptions) => void
|
||||||
onArchiveSession: (session: Session) => Promise<void>
|
onArchiveSession: (session: SessionInfo) => Promise<void>
|
||||||
onSetHoverTarget: (element: HTMLElement) => void
|
onSetHoverTarget: (element: HTMLElement) => void
|
||||||
onSetThumbTrack: (element: HTMLDivElement) => void
|
onSetThumbTrack: (element: HTMLDivElement) => void
|
||||||
onSetContent: (element: HTMLDivElement) => void
|
onSetContent: (element: HTMLDivElement) => void
|
||||||
@@ -192,7 +192,7 @@ function HomeSessionLeading(props: {
|
|||||||
</Show>
|
</Show>
|
||||||
<SessionTabAvatarView
|
<SessionTabAvatarView
|
||||||
project={props.record.project}
|
project={props.record.project}
|
||||||
directory={props.record.session.directory}
|
directory={props.record.session.location.directory}
|
||||||
revealProjectOnHover={props.revealProjectOnHover}
|
revealProjectOnHover={props.revealProjectOnHover}
|
||||||
unread={props.unread}
|
unread={props.unread}
|
||||||
loading={props.loading}
|
loading={props.loading}
|
||||||
@@ -344,7 +344,7 @@ function HomeSessionSearchResultRow(
|
|||||||
selected: boolean
|
selected: boolean
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
const title = createMemo(() => sessionLabel(props.record.session))
|
||||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||||
const key = () => homeSessionSearchKey(props.record)
|
const key = () => homeSessionSearchKey(props.record)
|
||||||
|
|
||||||
@@ -415,7 +415,7 @@ function HomeSessionGroupHeader(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
||||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
const title = createMemo(() => sessionLabel(props.record.session))
|
||||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
|
||||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { type ServerConnection, useServer } from "@/context/server"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { Logo } from "@opencode-ai/ui/logo"
|
|
||||||
import { useNavigate } from "@solidjs/router"
|
|
||||||
import { DateTime } from "luxon"
|
|
||||||
import { createMemo, For, Match, Switch } from "solid-js"
|
|
||||||
|
|
||||||
export function LegacyHome() {
|
|
||||||
const sync = useServerSync()
|
|
||||||
const pickDirectory = useDirectoryPicker()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const global = useGlobal()
|
|
||||||
const server = useServer()
|
|
||||||
const language = useLanguage()
|
|
||||||
const homedir = createMemo(() => sync().data.path.home)
|
|
||||||
const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
|
|
||||||
const recent = createMemo(() => {
|
|
||||||
return sync()
|
|
||||||
.data.project.slice()
|
|
||||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
|
||||||
.slice(0, 5)
|
|
||||||
})
|
|
||||||
|
|
||||||
const serverDotClass = createMemo(() => {
|
|
||||||
const healthy = global.servers.health[server.key]?.healthy
|
|
||||||
if (healthy === true) return "bg-icon-success-base"
|
|
||||||
if (healthy === false) return "bg-icon-critical-base"
|
|
||||||
return "bg-border-weak-base"
|
|
||||||
})
|
|
||||||
|
|
||||||
function openProject(conn: ServerConnection.Any, directory: string) {
|
|
||||||
const serverCtx = global.ensureServerCtx(conn)
|
|
||||||
serverCtx.projects.open(directory)
|
|
||||||
serverCtx.projects.touch(directory)
|
|
||||||
navigate(`/${base64Encode(directory)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function chooseProject() {
|
|
||||||
if (serverUnreachable()) return
|
|
||||||
const conn = server.current
|
|
||||||
if (!conn) return
|
|
||||||
|
|
||||||
const resolve = (result: string | string[] | null) => {
|
|
||||||
if (Array.isArray(result)) {
|
|
||||||
result.forEach((directory) => openProject(conn, directory))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (result) openProject(conn, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pickDirectory({
|
|
||||||
server: conn,
|
|
||||||
title: language.t("command.project.open"),
|
|
||||||
multiple: true,
|
|
||||||
onSelect: resolve,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="mx-auto mt-55 w-full md:w-auto px-4">
|
|
||||||
<Logo class="md:w-xl opacity-12" />
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
variant="ghost"
|
|
||||||
class="mt-4 mx-auto text-14-regular text-text-weak"
|
|
||||||
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"size-2 rounded-full": true,
|
|
||||||
[serverDotClass()]: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{server.name}
|
|
||||||
</Button>
|
|
||||||
<Switch>
|
|
||||||
<Match when={sync().data.project.length > 0}>
|
|
||||||
<div class="mt-20 w-full flex flex-col gap-4">
|
|
||||||
<div class="flex gap-2 items-center justify-between pl-3">
|
|
||||||
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
|
|
||||||
<Button
|
|
||||||
icon="folder-add-left"
|
|
||||||
size="normal"
|
|
||||||
class="pl-2 pr-3"
|
|
||||||
disabled={serverUnreachable()}
|
|
||||||
onClick={chooseProject}
|
|
||||||
>
|
|
||||||
{language.t("command.project.open")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<ul class="flex flex-col gap-2">
|
|
||||||
<For each={recent()}>
|
|
||||||
{(project) => (
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
variant="ghost"
|
|
||||||
class="text-14-mono text-left justify-between px-3"
|
|
||||||
onClick={() => openProject(server.current!, project.worktree)}
|
|
||||||
>
|
|
||||||
{project.worktree.replace(homedir(), "~")}
|
|
||||||
<div class="text-14-regular text-text-weak">
|
|
||||||
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={!sync().ready}>
|
|
||||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
|
||||||
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
|
|
||||||
<Button class="px-3" disabled={serverUnreachable()} onClick={chooseProject}>
|
|
||||||
{language.t("command.project.open")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
|
||||||
<Icon name="folder-add-left" size="large" />
|
|
||||||
<div class="flex flex-col gap-1 items-center justify-center">
|
|
||||||
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
|
|
||||||
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
|
|
||||||
</div>
|
|
||||||
<Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
|
|
||||||
{language.t("command.project.open")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { useNavigate } from "@solidjs/router"
|
|
||||||
import { DebugBar } from "@/components/debug-bar"
|
|
||||||
import { TabsInfoPopup } from "@/components/help-button"
|
|
||||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { setNavigate } from "@/utils/notification-click"
|
|
||||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
|
||||||
|
|
||||||
export default function NewLayout(props: ParentProps) {
|
|
||||||
const platform = usePlatform()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
setNavigate(navigate)
|
|
||||||
const [state, setState] = createStore({ debugTools: true })
|
|
||||||
|
|
||||||
createEffect(() => setV2Toast(true))
|
|
||||||
|
|
||||||
const update: TitlebarUpdate = {
|
|
||||||
version: () => {
|
|
||||||
const state = platform.updater?.state()
|
|
||||||
if (state?.status !== "ready") return
|
|
||||||
return state.version
|
|
||||||
},
|
|
||||||
installing: () => platform.updater?.state().status === "installing",
|
|
||||||
install: () => void platform.updater?.install(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
|
|
||||||
style={{
|
|
||||||
"padding-top": "env(safe-area-inset-top, 0px)",
|
|
||||||
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Titlebar
|
|
||||||
update={update}
|
|
||||||
debugTools={
|
|
||||||
import.meta.env.DEV
|
|
||||||
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
|
||||||
<Suspense>{props.children}</Suspense>
|
|
||||||
</main>
|
|
||||||
{import.meta.env.DEV && state.debugTools && <DebugBar inline />}
|
|
||||||
<TabsInfoPopup />
|
|
||||||
<ToastRegion v2 />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+30
-2396
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import {
|
|||||||
parseDeepLink,
|
parseDeepLink,
|
||||||
parseNewSessionDeepLink,
|
parseNewSessionDeepLink,
|
||||||
} from "./deep-links"
|
} from "./deep-links"
|
||||||
import type { Session } from "@/types"
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import {
|
import {
|
||||||
childSessionOnPath,
|
childSessionOnPath,
|
||||||
closeHomeProject,
|
closeHomeProject,
|
||||||
@@ -25,16 +25,18 @@ import { ServerConnection } from "@/context/server"
|
|||||||
|
|
||||||
const serverKey = ServerConnection.Key.make
|
const serverKey = ServerConnection.Key.make
|
||||||
|
|
||||||
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
|
const session = (input: Partial<SessionInfo> & Pick<SessionInfo, "id"> & { directory: string }) =>
|
||||||
({
|
({
|
||||||
|
projectID: "project",
|
||||||
title: "",
|
title: "",
|
||||||
version: "v2",
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
parentID: undefined,
|
parentID: undefined,
|
||||||
messageCount: 0,
|
|
||||||
permissions: { session: {}, share: {} },
|
|
||||||
time: { created: 0, updated: 0, archived: undefined },
|
time: { created: 0, updated: 0, archived: undefined },
|
||||||
...input,
|
...input,
|
||||||
}) as Session
|
location: { directory: input.directory },
|
||||||
|
directory: undefined,
|
||||||
|
}) as SessionInfo
|
||||||
|
|
||||||
describe("layout deep links", () => {
|
describe("layout deep links", () => {
|
||||||
test("parses open-project deep links", () => {
|
test("parses open-project deep links", () => {
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import type { Session } from "@/types"
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import type { ServerConnection } from "@/context/server"
|
import type { ServerConnection } from "@/context/server"
|
||||||
import type { HomeProjectSelection } from "@/context/layout"
|
import type { HomeProjectSelection } from "@/context/layout"
|
||||||
|
|
||||||
type SessionStore = {
|
type SessionStore = {
|
||||||
session?: Session[]
|
session?: SessionInfo[]
|
||||||
path: { directory: string }
|
path: { directory: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortSessions(now: number) {
|
function sortSessions(now: number) {
|
||||||
const oneMinuteAgo = now - 60 * 1000
|
const oneMinuteAgo = now - 60 * 1000
|
||||||
return (a: Session, b: Session) => {
|
return (a: SessionInfo, b: SessionInfo) => {
|
||||||
const aUpdated = a.time.updated ?? a.time.created
|
const aUpdated = a.time.updated ?? a.time.created
|
||||||
const bUpdated = b.time.updated ?? b.time.created
|
const bUpdated = b.time.updated ?? b.time.created
|
||||||
const aRecent = aUpdated > oneMinuteAgo
|
const aRecent = aUpdated > oneMinuteAgo
|
||||||
@@ -23,8 +23,8 @@ function sortSessions(now: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRootVisibleSession = (session: Session, directory: string) =>
|
const isRootVisibleSession = (session: SessionInfo, directory: string) =>
|
||||||
pathKey(session.directory) === pathKey(directory) && !session.parentID && !session.time?.archived
|
pathKey(session.location.directory) === pathKey(directory) && !session.parentID && !session.time.archived
|
||||||
|
|
||||||
export const roots = (store: SessionStore) =>
|
export const roots = (store: SessionStore) =>
|
||||||
(store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
|
(store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
|
||||||
@@ -41,7 +41,7 @@ export function hasProjectPermissions<T>(
|
|||||||
return Object.values(request ?? {}).some((list) => list?.some(include))
|
return Object.values(request ?? {}).some((list) => list?.some(include))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => {
|
export const childSessionOnPath = (sessions: SessionInfo[] | undefined, rootID: string, activeID?: string) => {
|
||||||
if (!activeID || activeID === rootID) return
|
if (!activeID || activeID === rootID) return
|
||||||
const map = new Map((sessions ?? []).map((session) => [session.id, session]))
|
const map = new Map((sessions ?? []).map((session) => [session.id, session]))
|
||||||
let id = activeID
|
let id = activeID
|
||||||
@@ -102,13 +102,13 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
|
export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
|
||||||
session: Session,
|
session: SessionInfo,
|
||||||
projects: T[],
|
projects: T[],
|
||||||
byID: Map<string, T> = new Map(projects.flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
byID: Map<string, T> = new Map(projects.flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||||
) {
|
) {
|
||||||
const direct = byID.get(session.projectID)
|
const direct = byID.get(session.projectID)
|
||||||
if (direct) return direct
|
if (direct) return direct
|
||||||
const directory = pathKey(session.directory)
|
const directory = pathKey(session.location.directory)
|
||||||
return projects.find(
|
return projects.find(
|
||||||
(project) =>
|
(project) =>
|
||||||
pathKey(project.worktree) === directory || project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
pathKey(project.worktree) === directory || project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { onCleanup, Show, type Accessor } from "solid-js"
|
|
||||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
|
||||||
|
|
||||||
export function createInlineEditorController() {
|
|
||||||
// This controller intentionally supports one active inline editor at a time.
|
|
||||||
const [editor, setEditor] = createStore({
|
|
||||||
active: "" as string,
|
|
||||||
value: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
const editorOpen = (id: string) => editor.active === id
|
|
||||||
const editorValue = () => editor.value
|
|
||||||
const openEditor = (id: string, value: string) => {
|
|
||||||
if (!id) return
|
|
||||||
setEditor({ active: id, value })
|
|
||||||
}
|
|
||||||
const closeEditor = () => setEditor({ active: "", value: "" })
|
|
||||||
|
|
||||||
const saveEditor = (callback: (next: string) => void) => {
|
|
||||||
const next = editor.value.trim()
|
|
||||||
if (!next) {
|
|
||||||
closeEditor()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
closeEditor()
|
|
||||||
callback(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault()
|
|
||||||
saveEditor(callback)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.key !== "Escape") return
|
|
||||||
event.preventDefault()
|
|
||||||
closeEditor()
|
|
||||||
}
|
|
||||||
|
|
||||||
const InlineEditor = (props: {
|
|
||||||
id: string
|
|
||||||
value: Accessor<string>
|
|
||||||
onSave: (next: string) => void
|
|
||||||
class?: string
|
|
||||||
displayClass?: string
|
|
||||||
editing?: boolean
|
|
||||||
stopPropagation?: boolean
|
|
||||||
openOnDblClick?: boolean
|
|
||||||
}) => {
|
|
||||||
let frame: number | undefined
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (frame === undefined) return
|
|
||||||
cancelAnimationFrame(frame)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isEditing = () => props.editing ?? editorOpen(props.id)
|
|
||||||
const stopEvents = () => props.stopPropagation ?? false
|
|
||||||
const allowDblClick = () => props.openOnDblClick ?? true
|
|
||||||
const stopPropagation = (event: Event) => {
|
|
||||||
if (!stopEvents()) return
|
|
||||||
event.stopPropagation()
|
|
||||||
}
|
|
||||||
const handleDblClick = (event: MouseEvent) => {
|
|
||||||
if (!allowDblClick()) return
|
|
||||||
stopPropagation(event)
|
|
||||||
openEditor(props.id, props.value())
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={isEditing()}
|
|
||||||
fallback={
|
|
||||||
<span
|
|
||||||
class={props.displayClass ?? props.class}
|
|
||||||
onDblClick={handleDblClick}
|
|
||||||
onPointerDown={stopPropagation}
|
|
||||||
onMouseDown={stopPropagation}
|
|
||||||
onClick={stopPropagation}
|
|
||||||
onTouchStart={stopPropagation}
|
|
||||||
>
|
|
||||||
{props.value()}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<InlineInput
|
|
||||||
ref={(el) => {
|
|
||||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
|
||||||
frame = requestAnimationFrame(() => {
|
|
||||||
frame = undefined
|
|
||||||
if (!el.isConnected) return
|
|
||||||
el.focus()
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
value={editorValue()}
|
|
||||||
class={props.class}
|
|
||||||
onInput={(event) => setEditor("value", event.currentTarget.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
editorKeyDown(event, props.onSave)
|
|
||||||
}}
|
|
||||||
onBlur={closeEditor}
|
|
||||||
onPointerDown={stopPropagation}
|
|
||||||
onClick={stopPropagation}
|
|
||||||
onDblClick={stopPropagation}
|
|
||||||
onMouseDown={stopPropagation}
|
|
||||||
onMouseUp={stopPropagation}
|
|
||||||
onTouchStart={stopPropagation}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
editor,
|
|
||||||
editorOpen,
|
|
||||||
editorValue,
|
|
||||||
openEditor,
|
|
||||||
closeEditor,
|
|
||||||
saveEditor,
|
|
||||||
editorKeyDown,
|
|
||||||
setEditor,
|
|
||||||
InlineEditor,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
import type { Session } from "@/types"
|
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
|
||||||
import { A, useParams } from "@solidjs/router"
|
|
||||||
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
|
||||||
import { useNotification } from "@/context/notification"
|
|
||||||
import { usePermission } from "@/context/permission"
|
|
||||||
import { messageAgentColor } from "@/utils/agent"
|
|
||||||
import { sessionTitle } from "@/utils/session-title"
|
|
||||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
|
||||||
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
|
|
||||||
|
|
||||||
export const ProjectIcon = (props: {
|
|
||||||
project: LocalProject
|
|
||||||
class?: string
|
|
||||||
notify?: boolean
|
|
||||||
working?: boolean
|
|
||||||
}): JSX.Element => {
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const notification = useNotification()
|
|
||||||
const permission = usePermission()
|
|
||||||
const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])])
|
|
||||||
const unseenCount = createMemo(() =>
|
|
||||||
dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
|
|
||||||
)
|
|
||||||
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
|
|
||||||
const hasPermissions = createMemo(() =>
|
|
||||||
dirs().some((directory) => {
|
|
||||||
return hasProjectPermissions(serverSync().session.data.permission, (item) => {
|
|
||||||
if (serverSync().session.get(item.sessionID)?.directory !== directory) return false
|
|
||||||
return !permission.autoResponds(item, directory)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
|
|
||||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
|
|
||||||
<div class="size-full rounded overflow-clip">
|
|
||||||
<Avatar
|
|
||||||
fallback={name()}
|
|
||||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
|
||||||
{...getAvatarColors(props.project.icon?.color)}
|
|
||||||
class="size-full rounded"
|
|
||||||
classList={{ "badge-mask": notify() }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Show when={notify()}>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"absolute top-px right-px size-1.5 rounded-full z-10": true,
|
|
||||||
"bg-surface-warning-strong": hasPermissions(),
|
|
||||||
"bg-icon-critical-base": !hasPermissions() && hasError(),
|
|
||||||
"bg-text-interactive-base": !hasPermissions() && !hasError(),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<Show when={props.working}>
|
|
||||||
<div class="absolute bottom-px right-px size-3 rounded-full bg-background-base z-10 flex items-center justify-center">
|
|
||||||
<Spinner class="size-[9px]" />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionItemProps = {
|
|
||||||
session: Session
|
|
||||||
list: Session[]
|
|
||||||
navList?: Accessor<Session[]>
|
|
||||||
slug: string
|
|
||||||
mobile?: boolean
|
|
||||||
dense?: boolean
|
|
||||||
showTooltip?: boolean
|
|
||||||
showChild?: boolean
|
|
||||||
level?: number
|
|
||||||
sidebarExpanded: Accessor<boolean>
|
|
||||||
clearHoverProjectSoon: () => void
|
|
||||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
|
||||||
archiveSession: (session: Session) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
const SessionRow = (props: {
|
|
||||||
session: Session
|
|
||||||
slug: string
|
|
||||||
mobile?: boolean
|
|
||||||
dense?: boolean
|
|
||||||
tint: Accessor<string | undefined>
|
|
||||||
isWorking: Accessor<boolean>
|
|
||||||
hasPermissions: Accessor<boolean>
|
|
||||||
hasError: Accessor<boolean>
|
|
||||||
unseenCount: Accessor<number>
|
|
||||||
clearHoverProjectSoon: () => void
|
|
||||||
sidebarOpened: Accessor<boolean>
|
|
||||||
warmPress: () => void
|
|
||||||
warmFocus: () => void
|
|
||||||
}): JSX.Element => {
|
|
||||||
const title = () => sessionTitle(props.session.title)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<A
|
|
||||||
href={`/${props.slug}/session/${props.session.id}`}
|
|
||||||
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
|
||||||
onPointerDown={props.warmPress}
|
|
||||||
onFocus={props.warmFocus}
|
|
||||||
onClick={() => {
|
|
||||||
if (props.sidebarOpened()) return
|
|
||||||
props.clearHoverProjectSoon()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={props.isWorking() || props.hasPermissions() || props.hasError() || props.unseenCount() > 0}>
|
|
||||||
<div
|
|
||||||
class="shrink-0 size-6 flex items-center justify-center"
|
|
||||||
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
|
||||||
>
|
|
||||||
<Switch>
|
|
||||||
<Match when={props.isWorking()}>
|
|
||||||
<Spinner class="size-[15px]" />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.hasPermissions()}>
|
|
||||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.hasError()}>
|
|
||||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.unseenCount() > 0}>
|
|
||||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span>
|
|
||||||
</A>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|
||||||
const params = useParams()
|
|
||||||
const layout = useLayout()
|
|
||||||
const language = useLanguage()
|
|
||||||
const notification = useNotification()
|
|
||||||
const permission = usePermission()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
|
|
||||||
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
|
|
||||||
const [sessionStore] = serverSync().child(props.session.directory)
|
|
||||||
const hasPermissions = createMemo(() => {
|
|
||||||
return !!sessionPermissionRequest(
|
|
||||||
sessionStore.session,
|
|
||||||
serverSync().session.data.permission,
|
|
||||||
props.session.id,
|
|
||||||
(item) => {
|
|
||||||
return !permission.autoResponds(item, props.session.directory)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const isWorking = createMemo(() => {
|
|
||||||
if (hasPermissions()) return false
|
|
||||||
return serverSync().session.data.session_working(props.session.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
const tint = createMemo(() =>
|
|
||||||
messageAgentColor(serverSync().session.data.message[props.session.id], sessionStore.agent),
|
|
||||||
)
|
|
||||||
const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded()))
|
|
||||||
const currentChild = createMemo(() => {
|
|
||||||
if (!props.showChild) return
|
|
||||||
return childSessionOnPath(sessionStore.session, props.session.id, params.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
const warm = (span: number, priority: "high" | "low") => {
|
|
||||||
const nav = props.navList?.()
|
|
||||||
const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
|
|
||||||
? nav
|
|
||||||
: props.list
|
|
||||||
|
|
||||||
props.prefetchSession(props.session, priority)
|
|
||||||
|
|
||||||
const idx = list.findIndex((item) => item.id === props.session.id && item.directory === props.session.directory)
|
|
||||||
if (idx === -1) return
|
|
||||||
|
|
||||||
for (let step = 1; step <= span; step++) {
|
|
||||||
const next = list[idx + step]
|
|
||||||
if (next) props.prefetchSession(next, step === 1 ? "high" : priority)
|
|
||||||
|
|
||||||
const prev = list[idx - step]
|
|
||||||
if (prev) props.prefetchSession(prev, step === 1 ? "high" : priority)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = (
|
|
||||||
<SessionRow
|
|
||||||
session={props.session}
|
|
||||||
slug={props.slug}
|
|
||||||
mobile={props.mobile}
|
|
||||||
dense={props.dense}
|
|
||||||
tint={tint}
|
|
||||||
isWorking={isWorking}
|
|
||||||
hasPermissions={hasPermissions}
|
|
||||||
hasError={hasError}
|
|
||||||
unseenCount={unseenCount}
|
|
||||||
clearHoverProjectSoon={props.clearHoverProjectSoon}
|
|
||||||
sidebarOpened={layout.sidebar.opened}
|
|
||||||
warmPress={() => warm(2, "high")}
|
|
||||||
warmFocus={() => warm(2, "high")}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
data-session-id={props.session.id}
|
|
||||||
class="group/session relative w-full min-w-0 rounded-md cursor-default pr-3 transition-colors hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
|
||||||
style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }}
|
|
||||||
>
|
|
||||||
<div class="flex min-w-0 items-center gap-1">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<Show
|
|
||||||
when={!tooltip()}
|
|
||||||
fallback={
|
|
||||||
<Tooltip
|
|
||||||
placement={props.mobile ? "bottom" : "right"}
|
|
||||||
value={sessionTitle(props.session.title)}
|
|
||||||
gutter={10}
|
|
||||||
class="min-w-0 w-full"
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* TODO: Restore the archive action when the V2 client exposes session archive. */}
|
|
||||||
<Show when={false}>
|
|
||||||
<div
|
|
||||||
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
|
||||||
classList={{
|
|
||||||
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
|
|
||||||
"w-0 opacity-0 pointer-events-none": !props.mobile,
|
|
||||||
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
|
||||||
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip value={language.t("common.archive")} placement="top">
|
|
||||||
<IconButton
|
|
||||||
icon="archive"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-6 rounded-md"
|
|
||||||
aria-label={language.t("common.archive")}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
void props.archiveSession(props.session)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={currentChild()} keyed>
|
|
||||||
{(child) => (
|
|
||||||
<div class="w-full">
|
|
||||||
<SessionItem {...props} session={child} level={(props.level ?? 0) + 1} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NewSessionItem = (props: {
|
|
||||||
slug: string
|
|
||||||
mobile?: boolean
|
|
||||||
dense?: boolean
|
|
||||||
sidebarExpanded: Accessor<boolean>
|
|
||||||
clearHoverProjectSoon: () => void
|
|
||||||
}): JSX.Element => {
|
|
||||||
const layout = useLayout()
|
|
||||||
const language = useLanguage()
|
|
||||||
const label = language.t("command.session.new")
|
|
||||||
const tooltip = () => props.mobile || !props.sidebarExpanded()
|
|
||||||
const item = (
|
|
||||||
<A
|
|
||||||
href={`/${props.slug}/session`}
|
|
||||||
end
|
|
||||||
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (layout.sidebar.opened()) return
|
|
||||||
props.clearHoverProjectSoon()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
|
||||||
<IconV2 name="edit" size="small" class="text-icon-weak" />
|
|
||||||
</div>
|
|
||||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
|
|
||||||
</A>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="group/session relative w-full min-w-0 rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
|
|
||||||
<Show
|
|
||||||
when={!tooltip()}
|
|
||||||
fallback={
|
|
||||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10} class="min-w-0 w-full">
|
|
||||||
{item}
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SessionSkeleton = (props: { count?: number }): JSX.Element => {
|
|
||||||
const items = Array.from({ length: props.count ?? 4 }, (_, index) => index)
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<For each={items}>
|
|
||||||
{() => <div class="h-8 w-full rounded-md bg-surface-raised-base opacity-60 animate-pulse" />}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { ContextMenu } from "@opencode-ai/ui/context-menu"
|
|
||||||
import { HoverCard } from "@opencode-ai/ui/hover-card"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
|
||||||
import { useLayout, type LocalProject } from "@/context/layout"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useNotification } from "@/context/notification"
|
|
||||||
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
|
|
||||||
import { displayName, sortedRootSessions } from "./helpers"
|
|
||||||
|
|
||||||
export type ProjectSidebarContext = {
|
|
||||||
currentDir: Accessor<string>
|
|
||||||
currentProject: Accessor<LocalProject | undefined>
|
|
||||||
sidebarOpened: Accessor<boolean>
|
|
||||||
sidebarHovering: Accessor<boolean>
|
|
||||||
hoverProject: Accessor<string | undefined>
|
|
||||||
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
|
||||||
onProjectMouseLeave: (worktree: string) => void
|
|
||||||
onProjectFocus: (worktree: string) => void
|
|
||||||
onHoverOpenChanged: (worktree: string, hovered: boolean) => void
|
|
||||||
navigateToProject: (directory: string) => void
|
|
||||||
openSidebar: () => void
|
|
||||||
closeProject: (directory: string) => void
|
|
||||||
showEditProjectDialog: (project: LocalProject) => void
|
|
||||||
toggleProjectWorkspaces: (project: LocalProject) => void
|
|
||||||
workspacesEnabled: (project: LocalProject) => boolean
|
|
||||||
workspaceIds: (project: LocalProject) => string[]
|
|
||||||
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
|
||||||
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "mobile" | "dense">
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProjectDragOverlay = (props: {
|
|
||||||
projects: Accessor<LocalProject[]>
|
|
||||||
activeProject: Accessor<string | undefined>
|
|
||||||
}): JSX.Element => {
|
|
||||||
const project = createMemo(() => props.projects().find((p) => p.worktree === props.activeProject()))
|
|
||||||
return (
|
|
||||||
<Show when={project()}>
|
|
||||||
{(p) => (
|
|
||||||
<div class="bg-background-base rounded-xl p-1">
|
|
||||||
<ProjectIcon project={p()} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProjectTile = (props: {
|
|
||||||
project: LocalProject
|
|
||||||
mobile?: boolean
|
|
||||||
sidebarHovering: Accessor<boolean>
|
|
||||||
selected: Accessor<boolean>
|
|
||||||
active: Accessor<boolean>
|
|
||||||
isWorking: Accessor<boolean>
|
|
||||||
overlay: Accessor<boolean>
|
|
||||||
suppressHover: Accessor<boolean>
|
|
||||||
dirs: Accessor<string[]>
|
|
||||||
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
|
||||||
onProjectMouseLeave: (worktree: string) => void
|
|
||||||
onProjectFocus: (worktree: string) => void
|
|
||||||
navigateToProject: (directory: string) => void
|
|
||||||
showEditProjectDialog: (project: LocalProject) => void
|
|
||||||
toggleProjectWorkspaces: (project: LocalProject) => void
|
|
||||||
workspacesEnabled: (project: LocalProject) => boolean
|
|
||||||
closeProject: (directory: string) => void
|
|
||||||
setMenu: (value: boolean) => void
|
|
||||||
setOpen: (value: boolean) => void
|
|
||||||
setSuppressHover: (value: boolean) => void
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
}): JSX.Element => {
|
|
||||||
const notification = useNotification()
|
|
||||||
const layout = useLayout()
|
|
||||||
const unseenCount = createMemo(() =>
|
|
||||||
props.dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
const clear = () =>
|
|
||||||
props
|
|
||||||
.dirs()
|
|
||||||
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
|
||||||
.forEach((directory) => notification.project.markViewed(directory))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu
|
|
||||||
modal={!props.sidebarHovering()}
|
|
||||||
onOpenChange={(value) => {
|
|
||||||
props.setMenu(value)
|
|
||||||
props.setSuppressHover(value)
|
|
||||||
if (value) props.setOpen(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ContextMenu.Trigger
|
|
||||||
as="button"
|
|
||||||
type="button"
|
|
||||||
aria-label={displayName(props.project)}
|
|
||||||
data-action="project-switch"
|
|
||||||
data-project={base64Encode(props.project.worktree)}
|
|
||||||
classList={{
|
|
||||||
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
|
||||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(),
|
|
||||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
|
||||||
!props.selected() && !props.active(),
|
|
||||||
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
|
|
||||||
}}
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
if (event.button === 0 && !event.ctrlKey) {
|
|
||||||
props.setOpen(false)
|
|
||||||
props.setSuppressHover(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!props.overlay()) return
|
|
||||||
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
|
|
||||||
props.setOpen(false)
|
|
||||||
props.setSuppressHover(true)
|
|
||||||
event.preventDefault()
|
|
||||||
}}
|
|
||||||
onMouseEnter={(event: MouseEvent) => {
|
|
||||||
if (!props.overlay()) return
|
|
||||||
if (props.suppressHover()) return
|
|
||||||
props.onProjectMouseEnter(props.project.worktree, event)
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
if (props.suppressHover()) props.setSuppressHover(false)
|
|
||||||
if (!props.overlay()) return
|
|
||||||
props.onProjectMouseLeave(props.project.worktree)
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
if (!props.overlay()) return
|
|
||||||
if (props.suppressHover()) return
|
|
||||||
props.onProjectFocus(props.project.worktree)
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
props.setOpen(false)
|
|
||||||
if (props.selected()) {
|
|
||||||
layout.sidebar.toggle()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
props.navigateToProject(props.project.worktree)
|
|
||||||
}}
|
|
||||||
onBlur={() => props.setOpen(false)}
|
|
||||||
>
|
|
||||||
<ProjectIcon project={props.project} notify working={props.isWorking()} />
|
|
||||||
</ContextMenu.Trigger>
|
|
||||||
<ContextMenu.Portal>
|
|
||||||
<ContextMenu.Content>
|
|
||||||
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
|
||||||
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
<ContextMenu.Item
|
|
||||||
data-action="project-workspaces-toggle"
|
|
||||||
data-project={base64Encode(props.project.worktree)}
|
|
||||||
disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
|
|
||||||
onSelect={() => props.toggleProjectWorkspaces(props.project)}
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemLabel>
|
|
||||||
{props.workspacesEnabled(props.project)
|
|
||||||
? props.language.t("sidebar.workspaces.disable")
|
|
||||||
: props.language.t("sidebar.workspaces.enable")}
|
|
||||||
</ContextMenu.ItemLabel>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
<ContextMenu.Item
|
|
||||||
data-action="project-clear-notifications"
|
|
||||||
data-project={base64Encode(props.project.worktree)}
|
|
||||||
disabled={unseenCount() === 0}
|
|
||||||
onSelect={clear}
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemLabel>{props.language.t("sidebar.project.clearNotifications")}</ContextMenu.ItemLabel>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
<ContextMenu.Separator />
|
|
||||||
<ContextMenu.Item
|
|
||||||
data-action="project-close-menu"
|
|
||||||
data-project={base64Encode(props.project.worktree)}
|
|
||||||
onSelect={() => props.closeProject(props.project.worktree)}
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Portal>
|
|
||||||
</ContextMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProjectPreviewPanel = (props: {
|
|
||||||
project: LocalProject
|
|
||||||
mobile?: boolean
|
|
||||||
selected: Accessor<boolean>
|
|
||||||
workspaceEnabled: Accessor<boolean>
|
|
||||||
workspaces: Accessor<string[]>
|
|
||||||
label: (directory: string) => string
|
|
||||||
projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
|
|
||||||
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
|
|
||||||
ctx: ProjectSidebarContext
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
}): JSX.Element => (
|
|
||||||
<div class="-m-3 p-2 flex flex-col w-72">
|
|
||||||
<div class="px-4 pt-2 pb-1 flex items-center gap-2">
|
|
||||||
<div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="px-4 pb-2 text-12-medium text-text-weak">{props.language.t("sidebar.project.recentSessions")}</div>
|
|
||||||
<div class="px-2 pb-2 flex flex-col gap-2">
|
|
||||||
<Show
|
|
||||||
when={props.workspaceEnabled()}
|
|
||||||
fallback={
|
|
||||||
<For each={props.projectSessions().slice(0, 2)}>
|
|
||||||
{(session) => (
|
|
||||||
<SessionItem
|
|
||||||
{...props.ctx.sessionProps}
|
|
||||||
session={session}
|
|
||||||
list={props.projectSessions()}
|
|
||||||
slug={base64Encode(props.project.worktree)}
|
|
||||||
dense
|
|
||||||
showTooltip
|
|
||||||
mobile={props.mobile}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={props.workspaces()}>
|
|
||||||
{(directory) => {
|
|
||||||
const sessions = createMemo(() => props.workspaceSessions(directory))
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
|
|
||||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
|
||||||
<Icon name="branch" size="small" class="text-icon-base" />
|
|
||||||
</div>
|
|
||||||
<span class="truncate text-14-medium text-text-base">{props.label(directory)}</span>
|
|
||||||
</div>
|
|
||||||
<For each={sessions().slice(0, 2)}>
|
|
||||||
{(session) => (
|
|
||||||
<SessionItem
|
|
||||||
{...props.ctx.sessionProps}
|
|
||||||
session={session}
|
|
||||||
list={sessions()}
|
|
||||||
slug={base64Encode(directory)}
|
|
||||||
dense
|
|
||||||
showTooltip
|
|
||||||
mobile={props.mobile}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="px-2 py-2 border-t border-border-weak-base">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
|
||||||
onClick={() => {
|
|
||||||
props.ctx.openSidebar()
|
|
||||||
props.ctx.onHoverOpenChanged(props.project.worktree, false)
|
|
||||||
if (props.selected()) return
|
|
||||||
props.ctx.navigateToProject(props.project.worktree)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.language.t("sidebar.project.viewAllSessions")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const SortableProject = (props: {
|
|
||||||
project: LocalProject
|
|
||||||
mobile?: boolean
|
|
||||||
ctx: ProjectSidebarContext
|
|
||||||
sortNow: Accessor<number>
|
|
||||||
}): JSX.Element => {
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const language = useLanguage()
|
|
||||||
const sortable = createSortable(props.project.worktree)
|
|
||||||
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
|
|
||||||
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
|
|
||||||
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
|
||||||
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
|
||||||
const [state, setState] = createStore({
|
|
||||||
menu: false,
|
|
||||||
suppressHover: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree
|
|
||||||
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
|
|
||||||
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
|
|
||||||
const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject()))
|
|
||||||
|
|
||||||
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
|
|
||||||
|
|
||||||
const label = (directory: string) => {
|
|
||||||
const [data] = serverSync().child(directory, { bootstrap: false })
|
|
||||||
const kind =
|
|
||||||
directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
|
|
||||||
const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id)
|
|
||||||
return `${kind} : ${name}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectStore = createMemo(() => serverSync().child(props.project.worktree, { bootstrap: false })[0])
|
|
||||||
const isWorking = createMemo(() =>
|
|
||||||
dirs().some((directory) => {
|
|
||||||
return Object.keys(serverSync().session.data.session_status).some((id) => {
|
|
||||||
if (serverSync().session.get(id)?.directory !== directory) return false
|
|
||||||
return serverSync().session.data.session_working(id)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
|
|
||||||
const workspaceSessions = (directory: string) => {
|
|
||||||
const [data] = serverSync().child(directory, { bootstrap: false })
|
|
||||||
return sortedRootSessions(data, props.sortNow())
|
|
||||||
}
|
|
||||||
const tile = () => (
|
|
||||||
<ProjectTile
|
|
||||||
project={props.project}
|
|
||||||
mobile={props.mobile}
|
|
||||||
sidebarHovering={props.ctx.sidebarHovering}
|
|
||||||
selected={selected}
|
|
||||||
active={active}
|
|
||||||
isWorking={isWorking}
|
|
||||||
overlay={overlay}
|
|
||||||
suppressHover={() => state.suppressHover}
|
|
||||||
dirs={dirs}
|
|
||||||
onProjectMouseEnter={props.ctx.onProjectMouseEnter}
|
|
||||||
onProjectMouseLeave={props.ctx.onProjectMouseLeave}
|
|
||||||
onProjectFocus={props.ctx.onProjectFocus}
|
|
||||||
navigateToProject={props.ctx.navigateToProject}
|
|
||||||
showEditProjectDialog={props.ctx.showEditProjectDialog}
|
|
||||||
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
|
|
||||||
workspacesEnabled={props.ctx.workspacesEnabled}
|
|
||||||
closeProject={props.ctx.closeProject}
|
|
||||||
setMenu={(value) => setState("menu", value)}
|
|
||||||
setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
|
|
||||||
setSuppressHover={(value) => setState("suppressHover", value)}
|
|
||||||
language={language}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
// @ts-ignore
|
|
||||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
|
||||||
<Show when={preview() && !selected()} fallback={tile()}>
|
|
||||||
<HoverCard
|
|
||||||
open={!state.suppressHover && hoverOpen() && !state.menu}
|
|
||||||
openDelay={0}
|
|
||||||
closeDelay={0}
|
|
||||||
placement="right-start"
|
|
||||||
gutter={6}
|
|
||||||
trigger={tile()}
|
|
||||||
onOpenChange={(value) => {
|
|
||||||
if (state.menu) return
|
|
||||||
if (value && state.suppressHover) return
|
|
||||||
props.ctx.onHoverOpenChanged(props.project.worktree, value)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ProjectPreviewPanel
|
|
||||||
project={props.project}
|
|
||||||
mobile={props.mobile}
|
|
||||||
selected={selected}
|
|
||||||
workspaceEnabled={workspaceEnabled}
|
|
||||||
workspaces={workspaces}
|
|
||||||
label={label}
|
|
||||||
projectSessions={projectSessions}
|
|
||||||
workspaceSessions={workspaceSessions}
|
|
||||||
ctx={props.ctx}
|
|
||||||
language={language}
|
|
||||||
/>
|
|
||||||
</HoverCard>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
|
||||||
import {
|
|
||||||
DragDropProvider,
|
|
||||||
DragDropSensors,
|
|
||||||
DragOverlay,
|
|
||||||
SortableProvider,
|
|
||||||
closestCenter,
|
|
||||||
type DragEvent,
|
|
||||||
} from "@thisbeyond/solid-dnd"
|
|
||||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
|
||||||
import { type LocalProject } from "@/context/layout"
|
|
||||||
|
|
||||||
export const SidebarContent = (props: {
|
|
||||||
mobile?: boolean
|
|
||||||
opened: Accessor<boolean>
|
|
||||||
aimMove: (event: MouseEvent) => void
|
|
||||||
projects: Accessor<LocalProject[]>
|
|
||||||
renderProject: (project: LocalProject) => JSX.Element
|
|
||||||
handleDragStart: (event: unknown) => void
|
|
||||||
handleDragEnd: () => void
|
|
||||||
handleDragOver: (event: DragEvent) => void
|
|
||||||
openProjectLabel: JSX.Element
|
|
||||||
openProjectKeybind: Accessor<string | undefined>
|
|
||||||
onOpenProject: () => void
|
|
||||||
renderProjectOverlay: () => JSX.Element
|
|
||||||
settingsLabel: Accessor<string>
|
|
||||||
settingsKeybind: Accessor<string | undefined>
|
|
||||||
onOpenSettings: () => void
|
|
||||||
helpLabel: Accessor<string>
|
|
||||||
onOpenHelp: () => void
|
|
||||||
renderPanel: () => JSX.Element
|
|
||||||
}): JSX.Element => {
|
|
||||||
const expanded = createMemo(() => !!props.mobile || props.opened())
|
|
||||||
const placement = () => (props.mobile ? "bottom" : "right")
|
|
||||||
let panel: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const el = panel
|
|
||||||
if (!el) return
|
|
||||||
if (expanded()) {
|
|
||||||
el.removeAttribute("inert")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
el.setAttribute("inert", "")
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex h-full w-full min-w-0 overflow-hidden">
|
|
||||||
<div
|
|
||||||
data-component="sidebar-rail"
|
|
||||||
class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"
|
|
||||||
onMouseMove={props.aimMove}
|
|
||||||
>
|
|
||||||
<div class="flex-1 min-h-0 w-full">
|
|
||||||
<DragDropProvider
|
|
||||||
onDragStart={props.handleDragStart}
|
|
||||||
onDragEnd={props.handleDragEnd}
|
|
||||||
onDragOver={props.handleDragOver}
|
|
||||||
collisionDetector={closestCenter}
|
|
||||||
>
|
|
||||||
<DragDropSensors />
|
|
||||||
<ConstrainDragXAxis />
|
|
||||||
<div class="h-full w-full flex flex-col items-center gap-3 px-3 py-3 overflow-y-auto no-scrollbar">
|
|
||||||
<SortableProvider ids={props.projects().map((p) => p.worktree)}>
|
|
||||||
<For each={props.projects()}>{(project) => props.renderProject(project)}</For>
|
|
||||||
</SortableProvider>
|
|
||||||
<Tooltip
|
|
||||||
placement={placement()}
|
|
||||||
value={
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{props.openProjectLabel}</span>
|
|
||||||
<Show when={!props.mobile && !!props.openProjectKeybind()}>
|
|
||||||
<span class="text-icon-base text-12-medium">{props.openProjectKeybind()}</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
icon="plus"
|
|
||||||
variant="ghost"
|
|
||||||
size="large"
|
|
||||||
onClick={props.onOpenProject}
|
|
||||||
aria-label={typeof props.openProjectLabel === "string" ? props.openProjectLabel : undefined}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<DragOverlay>{props.renderProjectOverlay()}</DragOverlay>
|
|
||||||
</DragDropProvider>
|
|
||||||
</div>
|
|
||||||
<div class="shrink-0 w-full pt-3 pb-6 flex flex-col items-center gap-2">
|
|
||||||
<TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}>
|
|
||||||
<IconButton
|
|
||||||
icon="settings-gear"
|
|
||||||
variant="ghost"
|
|
||||||
size="large"
|
|
||||||
onClick={props.onOpenSettings}
|
|
||||||
aria-label={props.settingsLabel()}
|
|
||||||
/>
|
|
||||||
</TooltipKeybind>
|
|
||||||
<Tooltip placement={placement()} value={props.helpLabel()}>
|
|
||||||
<IconButton
|
|
||||||
icon="help"
|
|
||||||
variant="ghost"
|
|
||||||
size="large"
|
|
||||||
onClick={props.onOpenHelp}
|
|
||||||
aria-label={props.helpLabel()}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={(el) => {
|
|
||||||
panel = el
|
|
||||||
}}
|
|
||||||
classList={{ "flex-1 flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
|
|
||||||
aria-hidden={!expanded()}
|
|
||||||
>
|
|
||||||
{props.renderPanel()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,489 +0,0 @@
|
|||||||
import { useNavigate, useParams } from "@solidjs/router"
|
|
||||||
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
||||||
import type { Session } from "@/types"
|
|
||||||
import { type LocalProject } from "@/context/layout"
|
|
||||||
import { useServerSync, useQueryOptions } from "@/context/server-sync"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { pathKey } from "@/utils/path-key"
|
|
||||||
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
|
||||||
import { sortedRootSessions } from "./helpers"
|
|
||||||
import { useIsFetching } from "@tanstack/solid-query"
|
|
||||||
|
|
||||||
type InlineEditorComponent = (props: {
|
|
||||||
id: string
|
|
||||||
value: Accessor<string>
|
|
||||||
onSave: (next: string) => void
|
|
||||||
class?: string
|
|
||||||
displayClass?: string
|
|
||||||
editing?: boolean
|
|
||||||
stopPropagation?: boolean
|
|
||||||
openOnDblClick?: boolean
|
|
||||||
}) => JSX.Element
|
|
||||||
|
|
||||||
export type WorkspaceSidebarContext = {
|
|
||||||
currentDir: Accessor<string>
|
|
||||||
navList: Accessor<Session[]>
|
|
||||||
sidebarExpanded: Accessor<boolean>
|
|
||||||
sidebarHovering: Accessor<boolean>
|
|
||||||
clearHoverProjectSoon: () => void
|
|
||||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
|
||||||
archiveSession: (session: Session) => Promise<void>
|
|
||||||
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
|
|
||||||
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
|
|
||||||
editorOpen: (id: string) => boolean
|
|
||||||
openEditor: (id: string, value: string) => void
|
|
||||||
closeEditor: () => void
|
|
||||||
setEditor: (key: "value", value: string) => void
|
|
||||||
InlineEditor: InlineEditorComponent
|
|
||||||
isBusy: (directory: string) => boolean
|
|
||||||
workspaceExpanded: (directory: string, local: boolean) => boolean
|
|
||||||
setWorkspaceExpanded: (directory: string, value: boolean) => void
|
|
||||||
showResetWorkspaceDialog: (root: string, directory: string) => void
|
|
||||||
showDeleteWorkspaceDialog: (root: string, directory: string) => void
|
|
||||||
setScrollContainerRef: (el: HTMLDivElement | undefined, mobile?: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WorkspaceDragOverlay = (props: {
|
|
||||||
sidebarProject: Accessor<LocalProject | undefined>
|
|
||||||
activeWorkspace: Accessor<string | undefined>
|
|
||||||
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
|
||||||
}): JSX.Element => {
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const language = useLanguage()
|
|
||||||
const label = createMemo(() => {
|
|
||||||
const project = props.sidebarProject()
|
|
||||||
if (!project) return
|
|
||||||
const directory = props.activeWorkspace()
|
|
||||||
if (!directory) return
|
|
||||||
|
|
||||||
const [workspaceStore] = serverSync().child(directory, { bootstrap: false })
|
|
||||||
const kind =
|
|
||||||
directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
|
|
||||||
const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
|
|
||||||
return `${kind} : ${name}`
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={label()}>
|
|
||||||
{(value) => <div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const WorkspaceHeader = (props: {
|
|
||||||
local: Accessor<boolean>
|
|
||||||
busy: Accessor<boolean>
|
|
||||||
open: Accessor<boolean>
|
|
||||||
directory: string
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
branch: Accessor<string | undefined>
|
|
||||||
workspaceValue: Accessor<string>
|
|
||||||
workspaceEditActive: Accessor<boolean>
|
|
||||||
InlineEditor: WorkspaceSidebarContext["InlineEditor"]
|
|
||||||
renameWorkspace: WorkspaceSidebarContext["renameWorkspace"]
|
|
||||||
setEditor: WorkspaceSidebarContext["setEditor"]
|
|
||||||
projectId?: string
|
|
||||||
}): JSX.Element => (
|
|
||||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
|
||||||
<div class="flex items-center justify-center shrink-0 size-6">
|
|
||||||
<Show when={props.busy()} fallback={<Icon name="branch" size="small" />}>
|
|
||||||
<Spinner class="size-[15px]" />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<span class="text-14-medium text-text-base shrink-0">
|
|
||||||
{props.local() ? props.language.t("workspace.type.local") : props.language.t("workspace.type.sandbox")} :
|
|
||||||
</span>
|
|
||||||
<Show
|
|
||||||
when={!props.local()}
|
|
||||||
fallback={
|
|
||||||
<span class="text-14-medium text-text-base min-w-0 truncate">
|
|
||||||
{props.branch() ?? getFilename(props.directory)}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<props.InlineEditor
|
|
||||||
id={`workspace:${props.directory}`}
|
|
||||||
value={props.workspaceValue}
|
|
||||||
onSave={(next) => {
|
|
||||||
const trimmed = next.trim()
|
|
||||||
if (!trimmed) return
|
|
||||||
props.renameWorkspace(props.directory, trimmed, props.projectId, props.branch())
|
|
||||||
props.setEditor("value", props.workspaceValue())
|
|
||||||
}}
|
|
||||||
class="text-14-medium text-text-base min-w-0 truncate"
|
|
||||||
displayClass="text-14-medium text-text-base min-w-0 truncate"
|
|
||||||
editing={props.workspaceEditActive()}
|
|
||||||
stopPropagation={false}
|
|
||||||
openOnDblClick={false}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<div class="flex items-center justify-center shrink-0 overflow-hidden w-0 opacity-0 transition-all duration-200 group-hover/workspace:w-3.5 group-hover/workspace:opacity-100 group-focus-within/workspace:w-3.5 group-focus-within/workspace:opacity-100">
|
|
||||||
<Icon name={props.open() ? "chevron-down" : "chevron-right"} size="small" class="text-icon-base" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const WorkspaceActions = (props: {
|
|
||||||
directory: string
|
|
||||||
local: Accessor<boolean>
|
|
||||||
busy: Accessor<boolean>
|
|
||||||
menuOpen: Accessor<boolean>
|
|
||||||
pendingRename: Accessor<boolean>
|
|
||||||
setMenuOpen: (open: boolean) => void
|
|
||||||
setPendingRename: (value: boolean) => void
|
|
||||||
sidebarHovering: Accessor<boolean>
|
|
||||||
touch: Accessor<boolean>
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
workspaceValue: Accessor<string>
|
|
||||||
openEditor: WorkspaceSidebarContext["openEditor"]
|
|
||||||
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
|
|
||||||
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
|
|
||||||
root: string
|
|
||||||
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
|
|
||||||
navigateToNewSession: () => void
|
|
||||||
}): JSX.Element => (
|
|
||||||
<div
|
|
||||||
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
|
|
||||||
classList={{
|
|
||||||
"opacity-100 pointer-events-auto": props.menuOpen(),
|
|
||||||
"opacity-0 pointer-events-none": !props.menuOpen(),
|
|
||||||
"group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
|
|
||||||
"group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu
|
|
||||||
modal={!props.sidebarHovering()}
|
|
||||||
open={props.menuOpen()}
|
|
||||||
onOpenChange={(open) => props.setMenuOpen(open)}
|
|
||||||
>
|
|
||||||
<Tooltip value={props.language.t("common.moreOptions")} placement="top">
|
|
||||||
<DropdownMenu.Trigger
|
|
||||||
as={IconButton}
|
|
||||||
icon="dot-grid"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-6 rounded-md"
|
|
||||||
data-action="workspace-menu"
|
|
||||||
data-workspace={base64Encode(props.directory)}
|
|
||||||
aria-label={props.language.t("common.moreOptions")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
onCloseAutoFocus={(event) => {
|
|
||||||
if (!props.pendingRename()) return
|
|
||||||
event.preventDefault()
|
|
||||||
props.setPendingRename(false)
|
|
||||||
props.openEditor(`workspace:${props.directory}`, props.workspaceValue())
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
disabled={props.local()}
|
|
||||||
onSelect={() => {
|
|
||||||
props.setPendingRename(true)
|
|
||||||
props.setMenuOpen(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
// TODO: Restore reset when V2 exposes project-copy reset and instance disposal.
|
|
||||||
// onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
disabled={props.local() || props.busy()}
|
|
||||||
onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>{props.language.t("common.delete")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu>
|
|
||||||
<Show when={!props.touch()}>
|
|
||||||
<Tooltip value={props.language.t("command.session.new")} placement="top">
|
|
||||||
<IconButtonV2
|
|
||||||
icon={<IconV2 name="edit" size="small" />}
|
|
||||||
variant="ghost"
|
|
||||||
size="small"
|
|
||||||
class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
|
|
||||||
data-action="workspace-new-session"
|
|
||||||
data-workspace={base64Encode(props.directory)}
|
|
||||||
aria-label={props.language.t("command.session.new")}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
props.clearHoverProjectSoon()
|
|
||||||
props.navigateToNewSession()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const WorkspaceSessionList = (props: {
|
|
||||||
slug: Accessor<string>
|
|
||||||
mobile?: boolean
|
|
||||||
ctx: WorkspaceSidebarContext
|
|
||||||
showNew: Accessor<boolean>
|
|
||||||
loading: Accessor<boolean>
|
|
||||||
sessions: Accessor<Session[]>
|
|
||||||
hasMore: Accessor<boolean>
|
|
||||||
loadMore: () => Promise<void>
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
}): JSX.Element => (
|
|
||||||
<nav class="flex flex-col gap-1">
|
|
||||||
<Show when={props.showNew()}>
|
|
||||||
<NewSessionItem
|
|
||||||
slug={props.slug()}
|
|
||||||
mobile={props.mobile}
|
|
||||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
|
||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<Show when={props.loading()}>
|
|
||||||
<SessionSkeleton />
|
|
||||||
</Show>
|
|
||||||
<For each={props.sessions()}>
|
|
||||||
{(session) => (
|
|
||||||
<SessionItem
|
|
||||||
session={session}
|
|
||||||
list={props.sessions()}
|
|
||||||
navList={props.ctx.navList}
|
|
||||||
slug={props.slug()}
|
|
||||||
mobile={props.mobile}
|
|
||||||
showChild
|
|
||||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
|
||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
|
||||||
prefetchSession={props.ctx.prefetchSession}
|
|
||||||
archiveSession={props.ctx.archiveSession}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<Show when={props.hasMore()}>
|
|
||||||
<div class="relative w-full py-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10"
|
|
||||||
size="large"
|
|
||||||
onClick={(e: MouseEvent) => {
|
|
||||||
void props.loadMore()
|
|
||||||
;(e.currentTarget as HTMLButtonElement).blur()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.language.t("common.loadMore")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</nav>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const SortableWorkspace = (props: {
|
|
||||||
ctx: WorkspaceSidebarContext
|
|
||||||
directory: string
|
|
||||||
project: LocalProject
|
|
||||||
sortNow: Accessor<number>
|
|
||||||
mobile?: boolean
|
|
||||||
}): JSX.Element => {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const params = useParams()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const queryOptions = useQueryOptions()
|
|
||||||
const language = useLanguage()
|
|
||||||
const sortable = createSortable(props.directory)
|
|
||||||
const [workspaceStore, setWorkspaceStore] = serverSync().child(props.directory, { bootstrap: false })
|
|
||||||
const [menu, setMenu] = createStore({
|
|
||||||
open: false,
|
|
||||||
pendingRename: false,
|
|
||||||
})
|
|
||||||
const slug = createMemo(() => base64Encode(props.directory))
|
|
||||||
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
|
|
||||||
const local = createMemo(() => props.directory === props.project.worktree)
|
|
||||||
const active = createMemo(() => pathKey(props.ctx.currentDir()) === pathKey(props.directory))
|
|
||||||
const workspaceValue = createMemo(() => {
|
|
||||||
const branch = workspaceStore.vcs?.branch
|
|
||||||
const name = branch ?? getFilename(props.directory)
|
|
||||||
return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name
|
|
||||||
})
|
|
||||||
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
|
|
||||||
const boot = createMemo(() => open() || active())
|
|
||||||
const count = createMemo(() => sessions()?.length ?? 0)
|
|
||||||
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
|
|
||||||
const fetching = useIsFetching(() => queryOptions().sessions(pathKey(props.directory)))
|
|
||||||
const busy = createMemo(() => props.ctx.isBusy(props.directory))
|
|
||||||
const loading = () => fetching() > 0 && count() === 0
|
|
||||||
const touch = createMediaQuery("(hover: none)")
|
|
||||||
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
|
|
||||||
const loadMore = async () => {
|
|
||||||
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
|
|
||||||
await serverSync().project.loadSessions(props.directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
|
|
||||||
const header = () => (
|
|
||||||
<WorkspaceHeader
|
|
||||||
local={local}
|
|
||||||
busy={busy}
|
|
||||||
open={open}
|
|
||||||
directory={props.directory}
|
|
||||||
language={language}
|
|
||||||
branch={() => workspaceStore.vcs?.branch}
|
|
||||||
workspaceValue={workspaceValue}
|
|
||||||
workspaceEditActive={workspaceEditActive}
|
|
||||||
InlineEditor={props.ctx.InlineEditor}
|
|
||||||
renameWorkspace={props.ctx.renameWorkspace}
|
|
||||||
setEditor={props.ctx.setEditor}
|
|
||||||
projectId={props.project.id}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
const openWrapper = (value: boolean) => {
|
|
||||||
props.ctx.setWorkspaceExpanded(props.directory, value)
|
|
||||||
if (value) return
|
|
||||||
if (props.ctx.editorOpen(`workspace:${props.directory}`)) props.ctx.closeEditor()
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!boot()) return
|
|
||||||
serverSync().child(props.directory, { bootstrap: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
// @ts-ignore
|
|
||||||
use:sortable
|
|
||||||
classList={{
|
|
||||||
"opacity-30": sortable.isActiveDraggable,
|
|
||||||
"opacity-50 pointer-events-none": busy(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
|
|
||||||
<div class="py-1">
|
|
||||||
<div
|
|
||||||
class="group/workspace relative"
|
|
||||||
data-component="workspace-item"
|
|
||||||
data-workspace={base64Encode(props.directory)}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<Show
|
|
||||||
when={workspaceEditActive()}
|
|
||||||
fallback={
|
|
||||||
<Collapsible.Trigger
|
|
||||||
class={`flex items-center justify-between w-full pl-2 py-1.5 rounded-md hover:bg-surface-raised-base-hover transition-[padding] duration-200 ${
|
|
||||||
menu.open ? "pr-16" : "pr-2"
|
|
||||||
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
|
||||||
data-action="workspace-toggle"
|
|
||||||
data-workspace={base64Encode(props.directory)}
|
|
||||||
>
|
|
||||||
{header()}
|
|
||||||
</Collapsible.Trigger>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class={`flex items-center justify-between w-full pl-2 py-1.5 rounded-md transition-[padding] duration-200 ${
|
|
||||||
menu.open ? "pr-16" : "pr-2"
|
|
||||||
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
|
||||||
>
|
|
||||||
{header()}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<WorkspaceActions
|
|
||||||
directory={props.directory}
|
|
||||||
local={local}
|
|
||||||
busy={busy}
|
|
||||||
menuOpen={() => menu.open}
|
|
||||||
pendingRename={() => menu.pendingRename}
|
|
||||||
setMenuOpen={(open) => setMenu("open", open)}
|
|
||||||
setPendingRename={(value) => setMenu("pendingRename", value)}
|
|
||||||
sidebarHovering={props.ctx.sidebarHovering}
|
|
||||||
touch={touch}
|
|
||||||
language={language}
|
|
||||||
workspaceValue={workspaceValue}
|
|
||||||
openEditor={props.ctx.openEditor}
|
|
||||||
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
|
|
||||||
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
|
|
||||||
root={props.project.worktree}
|
|
||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
|
||||||
navigateToNewSession={() => navigate(`/${slug()}/session`)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Collapsible.Content>
|
|
||||||
<WorkspaceSessionList
|
|
||||||
slug={slug}
|
|
||||||
mobile={props.mobile}
|
|
||||||
ctx={props.ctx}
|
|
||||||
showNew={showNew}
|
|
||||||
loading={loading}
|
|
||||||
sessions={sessions}
|
|
||||||
hasMore={hasMore}
|
|
||||||
loadMore={loadMore}
|
|
||||||
language={language}
|
|
||||||
/>
|
|
||||||
</Collapsible.Content>
|
|
||||||
</Collapsible>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LocalWorkspace = (props: {
|
|
||||||
ctx: WorkspaceSidebarContext
|
|
||||||
project: LocalProject
|
|
||||||
sortNow: Accessor<number>
|
|
||||||
mobile?: boolean
|
|
||||||
}): JSX.Element => {
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const queryOptions = useQueryOptions()
|
|
||||||
const language = useLanguage()
|
|
||||||
const workspace = createMemo(() => {
|
|
||||||
const [store, setStore] = serverSync().child(props.project.worktree)
|
|
||||||
return { store, setStore }
|
|
||||||
})
|
|
||||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
|
||||||
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
|
||||||
const count = createMemo(() => sessions()?.length ?? 0)
|
|
||||||
const fetching = useIsFetching(() => queryOptions().sessions(pathKey(props.project.worktree)))
|
|
||||||
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
|
|
||||||
const loading = () => fetching() > 0 && count() === 0
|
|
||||||
const loadMore = async () => {
|
|
||||||
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
|
||||||
await serverSync().project.loadSessions(props.project.worktree)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
|
|
||||||
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
|
||||||
>
|
|
||||||
<WorkspaceSessionList
|
|
||||||
slug={slug}
|
|
||||||
mobile={props.mobile}
|
|
||||||
ctx={props.ctx}
|
|
||||||
showNew={() => false}
|
|
||||||
loading={loading}
|
|
||||||
sessions={sessions}
|
|
||||||
hasMore={hasMore}
|
|
||||||
loadMore={loadMore}
|
|
||||||
language={language}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { FilePart, Project, UserMessage, VcsFileDiff } from "@/types"
|
import type { FilePart, Project, UserMessage } from "@/types"
|
||||||
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||||
@@ -159,7 +160,7 @@ export function SessionPage() {
|
|||||||
export function TargetSessionRouteContent() {
|
export function TargetSessionRouteContent() {
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
const params = useParams<{ serverKey: string; id: string }>()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory)
|
const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.location.directory)
|
||||||
return (
|
return (
|
||||||
// Settings must keep the target-server SDK, sync, and models context and remain registered
|
// Settings must keep the target-server SDK, sync, and models context and remain registered
|
||||||
// when session content falls back to the route error boundary.
|
// when session content falls back to the route error boundary.
|
||||||
@@ -252,7 +253,7 @@ function ResolvedTargetSessionRoute() {
|
|||||||
() => params.id,
|
() => params.id,
|
||||||
() => sync().session.lineage,
|
() => sync().session.lineage,
|
||||||
)
|
)
|
||||||
const directory = createMemo(() => current()?.session.directory)
|
const directory = createMemo(() => current()?.session.location.directory)
|
||||||
const targetDirectory = () => directory()!
|
const targetDirectory = () => directory()!
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -718,13 +719,13 @@ export default function Page() {
|
|||||||
if (reviewMode() === "git" || reviewMode() === "branch") return !vcsQuery.isPending
|
if (reviewMode() === "git" || reviewMode() === "branch") return !vcsQuery.isPending
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const loadReviewDiff = async (file: string, version?: number): Promise<VcsFileDiff | undefined> => {
|
const loadReviewDiff = async (file: string, version?: number): Promise<FileDiffInfo | undefined> => {
|
||||||
const mode = vcsMode()
|
const mode = vcsMode()
|
||||||
if (!mode) return
|
if (!mode) return
|
||||||
const root = reviewRootDirectory(sync().project?.worktree ?? sdk().directory)
|
const root = reviewRootDirectory(sync().project?.worktree ?? sdk().directory)
|
||||||
const directory = reviewDiffDirectory(root, file)
|
const directory = reviewDiffDirectory(root, file)
|
||||||
const source = reviewDiffs().find((diff) => diff.file === file)
|
const source = reviewDiffs().find((diff) => diff.file === file)
|
||||||
const valid = (diff: VcsFileDiff | undefined) => {
|
const valid = (diff: FileDiffInfo | undefined) => {
|
||||||
if (!diff || !source) return
|
if (!diff || !source) return
|
||||||
if (diff.additions !== source.additions || diff.deletions !== source.deletions) return
|
if (diff.additions !== source.additions || diff.deletions !== source.deletions) return
|
||||||
if (reviewDiffNeedsLoad(diff)) return
|
if (reviewDiffNeedsLoad(diff)) return
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user