Compare commits

..

86 Commits

Author SHA1 Message Date
Aiden Cline bf539d9e43 fix(core): isolate integration form configuration 2026-08-06 23:08:30 -05:00
Aiden Cline bef565ff81 refactor(core): replace integration prompts with forms 2026-08-06 22:59:39 -05:00
Brendan Allan a1b4843a33 refactor(app): remove legacy layout (#40947) 2026-08-07 11:35:04 +08:00
Kit Langton cc7827fe08 refactor(core): simplify file tools to lexical paths (#40962) 2026-08-06 21:53:52 -04:00
opencode-agent[bot] 76e4d88d21 fix(core): default custom agents to primary (#40880)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-06 20:47:51 -05:00
opencode-agent[bot] 439ed66c7b fix(core): migrate legacy small model (#40966)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-06 20:46:12 -05:00
Kit Langton 047d434aa2 fix(tui): dismiss stale permission prompts (#40960) 2026-08-06 21:43:44 -04:00
opencode-agent[bot] d7651519f3 fix(tui): use tab layout setting (#40952)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-07 01:19:28 +00:00
Kit Langton 1eb3a43add fix(tui): keep model selection session scoped (#40913) 2026-08-06 20:34:57 -04:00
Aiden Cline dcae95e2bb feat(ai): expose model compatibility options (#40942) 2026-08-06 17:46:05 -05:00
James Long 0cc507b8a9 feat(cli): add session import and export (#40914) 2026-08-06 17:51:11 -04:00
opencode-agent[bot] 727beae2d5 fix(core): deduplicate websearch consent prompts (#40869)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-08-06 17:50:51 -04:00
Dax Raad cd64a17e37 test(core): cover config precedence 2026-08-06 17:26:35 -04:00
Dax Raad d35ca49c31 feat(tui): enable cwd-scoped session tabs by default 2026-08-06 17:26:06 -04:00
Dax 5cf97f1b96 feat(core): normalize mixed config formats (#40919) 2026-08-06 17:11:13 -04:00
Kit Langton 65c6a71903 fix(tui): simplify MCP status rows (#40916) 2026-08-06 16:50:58 -04:00
Kit Langton a779027303 fix(tui): compact single-line prompts (#40924) 2026-08-06 16:46:24 -04:00
Dax 20fa444f31 fix(config): omit unset optional values (#40918) 2026-08-06 13:06:03 -07:00
Kit Langton c66d84169a fix(tui): open authorization links (#40912) 2026-08-06 15:26:13 -04:00
Aiden Cline 7dbe8c4c13 refactor(mcp): remove unused registration status (#40904) 2026-08-06 13:59:22 -05:00
Aiden Cline 8864b01d0b feat(core): increase retained compaction context (#40906) 2026-08-06 13:45:06 -05:00
Aiden Cline ec95b27308 fix(core): align ChatGPT context limits (#40902) 2026-08-06 13:35:48 -05:00
Aiden Cline be2f74d44a refactor(plugin): split session HTTP hooks (#40724) 2026-08-06 13:23:22 -05:00
James Long 912a801060 refactor(drizzle): declare query variance (#40891) 2026-08-06 13:58:17 -04:00
Dax 0215498f63 fix(core): load well-known config first (#40897) 2026-08-06 10:44:26 -07:00
Dax 1539bd6794 fix(client): evict unresponsive service (#40896) 2026-08-06 10:37:05 -07:00
Dax 939da6a5e7 feat(cli): add debug config command (#40895) 2026-08-06 10:35:48 -07:00
Dax Raad 0f27ee7b4c fix(cli): reuse server for noninteractive commands 2026-08-06 12:57:37 -04:00
Kit Langton 51cef27579 refactor(core): deduplicate Copilot endpoint routing (#40765) 2026-08-06 11:10:47 -04:00
Brendan Allan a0c77b71ae refactor(app): use native session info (#40824) 2026-08-06 17:58:56 +08:00
Dax Raad e62918224f fix(cli): rename auth connect to login 2026-08-06 01:48:53 -04:00
Dax Raad 30d140008f feat(cli): add models command 2026-08-06 01:12:11 -04:00
Aiden Cline a38d44265b fix(core): continue interrupted responses (#40576) 2026-08-06 00:04:27 -05:00
Aiden Cline 0cce215de4 fix(ai): harden compatible chat parsing (#40798) 2026-08-05 23:54:41 -05:00
opencode-agent[bot] a15ec425de test: repair v2 CI failures (#40803)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-05 23:38:38 -05:00
Aiden Cline ae494ffce5 fix(core): retire legacy provider aliases (#40487) 2026-08-05 23:27:14 -05:00
Brendan Allan 25c2150baf refactor(app): use native v2 types (#40608) 2026-08-06 11:54:11 +08:00
Brendan Allan 9314992f98 refactor(app): remove v1 compatibility (#40382) 2026-08-06 11:11:58 +08:00
Dax 0fbe489e35 feat(core): migrate v1 data to v2 (#40723) 2026-08-05 21:57:58 -04:00
Kit Langton 03e9789064 test(core): align event persistence expectations (#40783) 2026-08-06 01:50:45 +00:00
Kit Langton 0c558a6856 refactor(console): remove unused mail assets (#40758) 2026-08-06 00:29:55 +00:00
Kit Langton f297bd3b8b refactor(desktop): remove disconnected CLI installer (#40751) 2026-08-06 00:29:37 +00:00
Kit Langton 1bfc23f503 refactor(console): remove unused landing assets (#40757) 2026-08-06 00:28:37 +00:00
Kit Langton 3839aafa25 refactor: remove orphaned sqlite package (#40766) 2026-08-06 00:00:40 +00:00
Dax Raad bd2f37bc90 fix(core): clarify current session label 2026-08-05 19:33:59 -04:00
Dax Raad 120e4e7388 fix(core): make event persistence opt-in 2026-08-05 19:33:59 -04:00
Kit Langton 6f91bc7415 fix(tui): load sidebar project names sooner (#40763) 2026-08-05 19:31:42 -04:00
Kit Langton c46f6ae112 refactor(app): remove unused help placeholder (#40756) 2026-08-05 18:53:25 -04:00
Kit Langton 285444aab4 refactor(web): remove superseded ornate logos (#40750) 2026-08-05 22:35:41 +00:00
Kit Langton 56c33e84a3 fix(tui): keep model search order stable (#40753) 2026-08-05 18:19:26 -04:00
Kit Langton 73581b3c3b refactor(web): remove unused icons (#40744) 2026-08-05 21:51:54 +00:00
Kit Langton 6e4d01f846 refactor(session-ui): remove unused panel title (#40741) 2026-08-05 21:50:46 +00:00
Kit Langton 8646587c95 refactor(web): remove unused share anchor (#40742) 2026-08-05 17:46:40 -04:00
Kit Langton fb47f06228 refactor(desktop): remove superseded local sidecar (#40743) 2026-08-05 17:46:19 -04:00
Kit Langton 3acaa5a359 refactor(core): remove unused shell posix metadata (#40734) 2026-08-05 21:31:35 +00:00
Kit Langton 0726a25142 refactor(console): remove abandoned desktop promo (#40737) 2026-08-05 21:30:53 +00:00
Kit Langton 0df96ddeb0 refactor(codemode): remove unused spread helper (#40729) 2026-08-05 21:26:33 +00:00
Kit Langton 887673310b refactor(core): remove unused session exports (#40730) 2026-08-05 21:26:16 +00:00
Kit Langton 693a1bff81 refactor(core): remove unused copy strategy registry (#40735) 2026-08-05 21:25:18 +00:00
Kit Langton b40ed3aa85 refactor(console): remove unused mail helpers (#40740) 2026-08-05 21:24:21 +00:00
Kit Langton 732bb9c3cb refactor(core): remove discarded MCP connection details (#40726) 2026-08-05 17:20:13 -04:00
Kit Langton 2fd1660b4d refactor(core): remove test-only ignore helper (#40733) 2026-08-05 21:15:22 +00:00
Kit Langton 681526d348 refactor(core): remove duplicate directory query (#40731) 2026-08-05 21:10:01 +00:00
Kit Langton e78869c849 refactor(core): remove unused agent default method (#40725) 2026-08-05 20:46:36 +00:00
Kit Langton 934935963d refactor(core): remove unused filesystem search methods (#40703) 2026-08-05 19:42:30 +00:00
Kit Langton f3912a2a8a refactor(app): remove unused UI helpers (#40686) 2026-08-05 15:15:15 -04:00
Kit Langton 45d58717a4 fix(core): bound protected home searches (#40518) 2026-08-05 15:15:02 -04:00
Kit Langton 74e3155ef0 refactor(session-ui): remove unused exported components (#40685) 2026-08-05 15:13:39 -04:00
Kit Langton 0af6c82563 refactor(core): remove orphaned message projector paths (#40705) 2026-08-05 15:13:15 -04:00
Kit Langton 686127f809 refactor(core): remove unreachable repository validation (#40700) 2026-08-05 14:30:29 -04:00
Kit Langton 5256655c4d refactor(core): remove obsolete project commit bridge (#40701) 2026-08-05 18:16:55 +00:00
Kit Langton d10b652637 refactor(core): remove unused formatter methods (#40684) 2026-08-05 13:45:23 -04:00
Kit Langton b03ca0d4e2 refactor(core): remove unused bus sequences (#40681) 2026-08-05 13:41:38 -04:00
Kit Langton 25aaea3d31 refactor(core): remove unused shell tree killer (#40680) 2026-08-05 13:38:59 -04:00
Kit Langton cae7a139bc refactor(server): remove obsolete auth header helpers (#40682) 2026-08-05 13:37:29 -04:00
Kit Langton 5ea62ab05f refactor(core): remove orphaned git change operations (#40683) 2026-08-05 13:32:03 -04:00
Kit Langton faadc05c88 refactor(core): remove unused snapshot operations (#40687) 2026-08-05 13:01:48 -04:00
Kit Langton 3e253c589e refactor(core): persist instruction updates as messages (#40679) 2026-08-05 12:55:29 -04:00
Kit Langton 0a0fc09533 refactor(core): remove unused file mutation methods (#40667) 2026-08-05 12:33:20 -04:00
Kit Langton 5aa0413fea refactor(core): remove dead fork pending copy (#40675) 2026-08-05 12:30:42 -04:00
Kit Langton 6f4c199629 fix(tui): wait for diff request in tests (#40670) 2026-08-05 16:02:22 +00:00
Kit Langton ed8e1f4654 fix(core): reconcile promoted prompt retries from messages (#40664) 2026-08-05 11:43:03 -04:00
Kit Langton 3b0195e045 fix(core): avoid eager directory snapshots (#40552) 2026-08-05 14:52:07 +00:00
Shoubhit Dash 143a776373 fix(acp): surface subagent activity (#40438) 2026-08-05 18:57:15 +05:30
Brendan Allan 76b318e990 refactor(app): gate unsupported v2 capabilities (#40381) 2026-08-05 15:15:26 +08:00
opencode-agent[bot] c0ab35c3c2 fix(app): show active search details (#40597)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-05 17:09:42 +10:00
488 changed files with 18117 additions and 31739 deletions
+1 -14
View File
@@ -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"],
+206 -26
View File
@@ -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.
+97 -33
View File
@@ -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,
+22 -23
View File
@@ -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 & {
+1
View File
@@ -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 {
+2 -2
View File
@@ -102,7 +102,7 @@ describe("llm constructors", () => {
const updated = LanguageModel.update(base, { const updated = LanguageModel.update(base, {
route: responsesRoute, route: responsesRoute,
defaults: { generation: { maxTokens: 20 } }, defaults: { generation: { maxTokens: 20 } },
compatibility: { toolSchema: "gemini" }, 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")
+21
View File
@@ -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}`,
@@ -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",
+1 -1
View File
@@ -20,7 +20,7 @@
<meta property="twitter:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script> <script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
</head> </head>
<body 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>
+1
View File
@@ -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
View File
@@ -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 (
@@ -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>
)
}
-148
View File
@@ -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"
+1 -1
View File
@@ -81,7 +81,7 @@ import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/place
import { createPromptInputTransientState } from "./prompt-input/transient-state" import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
import type { ReferenceInfo } from "@/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"),
@@ -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">&quot;{props.filter}&quot;</span>
</Show>
</div>
)
}
export const SettingsModels: Component = () => {
return (
<SettingsServerScope>
<SettingsModelsContent />
</SettingsServerScope>
)
}
const SettingsModelsContent: Component = () => {
const language = useLanguage()
const models = useModels()
const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(),
key: (x) => `${x.provider.id}:${x.id}`,
filterKeys: ["provider.name", "name", "id"],
sortBy: (a, b) => a.name.localeCompare(b.name),
groupBy: (x) => x.provider.id,
sortGroupsBy: (a, b) => {
const aIndex = popularProviders.indexOf(a.category)
const bIndex = popularProviders.indexOf(b.category)
const aPopular = aIndex >= 0
const bPopular = bIndex >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
if (aPopular && bPopular) return aIndex - bIndex
const aName = a.items[0].provider.name
const bName = b.items[0].provider.name
return aName.localeCompare(bName)
},
})
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
<SettingsServerPicker />
</div>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
value={list.filter()}
onChange={list.onInput}
placeholder={language.t("dialog.model.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="flex-1"
/>
<Show when={list.filter()}>
<IconButton icon="circle-x" variant="ghost" onClick={list.clear} />
</Show>
</div>
</div>
</div>
<div class="flex flex-col gap-8 max-w-[720px]">
<Show
when={!list.grouped.loading}
fallback={
<ListLoadingState label={`${language.t("common.loading")}${language.t("common.loading.ellipsis")}`} />
}
>
<Show
when={list.flat().length > 0}
fallback={<ListEmptyState message={language.t("dialog.model.empty")} filter={list.filter()} />}
>
<For each={list.grouped.latest}>
{(group) => (
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 pb-2">
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
</div>
<SettingsList>
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<div class="min-w-0">
<span class="text-14-regular text-text-strong truncate block">{item.name}</span>
</div>
<div class="flex-shrink-0">
<Switch
checked={models.visible(key)}
onChange={(checked) => {
models.setVisibility(key, checked)
}}
hideLabel
>
{item.name}
</Switch>
</div>
</div>
)
}}
</For>
</SettingsList>
</div>
)}
</For>
</Show>
</Show>
</div>
</div>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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,
}) })
}) })
+1 -3
View File
@@ -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
} }
-5
View File
@@ -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
+5 -6
View File
@@ -1,11 +1,11 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import type { Message, Part, 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)
-7
View File
@@ -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(
+1 -30
View File
@@ -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,
-30
View File
@@ -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)
},
}, },
} }
}, },
+3 -3
View File
@@ -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) =>
({ ({
+3 -6
View File
@@ -1,7 +1,7 @@
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@/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) {
+5 -9
View File
@@ -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 -20
View File
@@ -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([])
}) })
+12 -17
View File
@@ -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)
+2 -4
View File
@@ -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" }])
}) })
+20 -3
View File
@@ -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)
-79
View File
@@ -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)
})
})
+7 -199
View File
@@ -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),
+5 -4
View File
@@ -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],
}) })
}) })
+1 -1
View File
@@ -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)
@@ -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 (
-142
View File
@@ -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>
)
}
-53
View File
@@ -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>
)
}
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", () => {
+8 -8
View File
@@ -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>
)
}
+6 -5
View File
@@ -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
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { PermissionRequest, QuestionRequest, Session } from "@/types" import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { todoDockAtBoundary, todoState } from "./session-composer-state" import { todoDockAtBoundary, todoState } from "./session-composer-state"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
@@ -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 = (id: string, sessionID: string) => const permission = (id: string, sessionID: string) =>
({ ({
@@ -1,6 +1,7 @@
import { createEffect, createMemo, on, onCleanup } from "solid-js" import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { PermissionRequest, QuestionRequest, Todo } from "@/types" import type { Todo } from "@/types"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
@@ -1,5 +1,5 @@
import { For, Show } from "solid-js" import { For, Show } from "solid-js"
import type { PermissionRequest } from "@/types" import type { PermissionRequest } from "@opencode-ai/client/promise"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt" import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -13,7 +13,7 @@ export function SessionPermissionDock(props: {
const language = useLanguage() const language = useLanguage()
const toolDescription = () => { const toolDescription = () => {
const key = `settings.permissions.tool.${props.request.permission}.description` const key = `settings.permissions.tool.${props.request.action}.description`
const value = language.t(key as Parameters<typeof language.t>[0]) const value = language.t(key as Parameters<typeof language.t>[0])
if (value === key) return "" if (value === key) return ""
return value return value
@@ -59,11 +59,11 @@ export function SessionPermissionDock(props: {
</div> </div>
</Show> </Show>
<Show when={props.request.patterns.length > 0}> <Show when={props.request.resources.length > 0}>
<div data-slot="permission-row"> <div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" /> <span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-patterns"> <div data-slot="permission-patterns">
<For each={props.request.patterns}> <For each={props.request.resources}>
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>} {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
</For> </For>
</div> </div>
@@ -6,7 +6,7 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { useSpring } from "@opencode-ai/ui/motion-spring"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import type { QuestionAnswer, QuestionRequest } from "@/types" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/client/promise"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
@@ -1,7 +1,8 @@
import type { PermissionRequest, QuestionRequest, Session } from "@/types" import type { SessionInfo } from "@opencode-ai/client/promise"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
function sessionTreeRequest<T>( function sessionTreeRequest<T>(
session: Session[], session: SessionInfo[],
request: Record<string, T[] | undefined>, request: Record<string, T[] | undefined>,
sessionID?: string, sessionID?: string,
include: (item: T) => boolean = () => true, include: (item: T) => boolean = () => true,
@@ -34,7 +35,7 @@ function sessionTreeRequest<T>(
} }
export function sessionPermissionRequest( export function sessionPermissionRequest(
session: Session[], session: SessionInfo[],
request: Record<string, PermissionRequest[] | undefined>, request: Record<string, PermissionRequest[] | undefined>,
sessionID?: string, sessionID?: string,
include?: (item: PermissionRequest) => boolean, include?: (item: PermissionRequest) => boolean,
@@ -43,7 +44,7 @@ export function sessionPermissionRequest(
} }
export function sessionQuestionRequest( export function sessionQuestionRequest(
session: Session[], session: SessionInfo[],
request: Record<string, QuestionRequest[] | undefined>, request: Record<string, QuestionRequest[] | undefined>,
sessionID?: string, sessionID?: string,
include?: (item: QuestionRequest) => boolean, include?: (item: QuestionRequest) => boolean,
@@ -2,7 +2,7 @@ import { For } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { DockShell } from "@opencode-ai/ui/dock-surface" import { DockShell } from "@opencode-ai/ui/dock-surface"
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import { SettingsProvider, useSettings } from "@/context/settings" import { SettingsProvider } from "@/context/settings"
export default { export default {
title: "Composer/Revert Dock", title: "Composer/Revert Dock",
@@ -21,9 +21,6 @@ Real \`SessionRevertDock\` from app code, rendered above a mock composer card.
The live composer overlaps the dock's bottom by 18px (\`session-composer-region-controller.ts\` \`lift()\`). The live composer overlaps the dock's bottom by 18px (\`session-composer-region-controller.ts\` \`lift()\`).
The card below reproduces that overlap so the collapsed/expanded cutoff behavior can be verified in isolation. The card below reproduces that overlap so the collapsed/expanded cutoff behavior can be verified in isolation.
### Layout split
Use the **Layout** button to toggle \`newLayoutDesigns\` and preview both the v2 dock and the legacy (v1) \`DockTray\` fallback.
### Notes ### Notes
- \`onRestore\` only mutates local story state, so nothing in the real session is affected. - \`onRestore\` only mutates local story state, so nothing in the real session is affected.
- Click the header to expand/collapse. Click "Restore message" to remove a row.`, - Click the header to expand/collapse. Click "Restore message" to remove a row.`,
@@ -53,11 +50,9 @@ const btn = (accent?: boolean) =>
}) as const }) as const
function Stage(props: { count: number }) { function Stage(props: { count: number }) {
const settings = useSettings()
const seed = () => messages.slice(0, props.count).map((text, index) => ({ id: `rolled-${index}`, text })) const seed = () => messages.slice(0, props.count).map((text, index) => ({ id: `rolled-${index}`, text }))
const [store, setStore] = createStore({ items: seed() }) const [store, setStore] = createStore({ items: seed() })
const v2 = () => settings.general.newLayoutDesigns()
const reset = () => setStore("items", seed()) const reset = () => setStore("items", seed())
const restore = (id: string) => const restore = (id: string) =>
setStore( setStore(
@@ -71,22 +66,15 @@ function Stage(props: { count: number }) {
<button style={btn()} onClick={reset}> <button style={btn()} onClick={reset}>
Reset ({props.count}) Reset ({props.count})
</button> </button>
<button style={btn(v2())} onClick={() => settings.general.setNewLayoutDesigns(!v2())}>
Layout: {v2() ? "v2" : "v1"}
</button>
</div> </div>
{/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */} {/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
<div style={{ display: "flex", "flex-direction": "column" }}> <div style={{ display: "flex", "flex-direction": "column" }}>
<SessionRevertDock items={store.items} onRestore={restore} /> <SessionRevertDock items={store.items} onRestore={restore} />
<DockShell <DockShell
data-dock-border-underlay={v2() ? "v2" : "legacy"} data-dock-border-underlay="v2"
style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }} style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }}
classList={{ class="min-h-24 w-full rounded-[12px] bg-v2-background-bg-base px-4 py-3 text-[13px] text-v2-text-text-faint"
"min-h-24 w-full rounded-[12px] px-4 py-3 text-[13px]": true,
"bg-v2-background-bg-base text-v2-text-text-faint": v2(),
"text-text-weak": !v2(),
}}
> >
Ask anything... Ask anything...
</DockShell> </DockShell>
@@ -22,8 +22,6 @@ type TabsInput = {
fileBrowser?: Accessor<boolean> fileBrowser?: Accessor<boolean>
} }
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) { export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
return input.opened && input.visible return input.opened && input.visible
} }
@@ -1,6 +1,5 @@
import { createEffect, onCleanup, type JSX } from "solid-js" import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import type { SnapshotFileDiff, VcsFileDiff } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { SessionReview } from "@opencode-ai/session-ui/session-review" import { SessionReview } from "@opencode-ai/session-ui/session-review"
import type { import type {
@@ -15,7 +14,7 @@ import type { LineComment } from "@/context/comments"
export type DiffStyle = "unified" | "split" export type DiffStyle = "unified" | "split"
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff type ReviewDiff = FileDiffInfo
export interface SessionReviewTabProps { export interface SessionReviewTabProps {
title?: JSX.Element title?: JSX.Element

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