Compare commits

..

6 Commits

Author SHA1 Message Date
Brendan Allan d06546b7ea fix(app): sync session selection before prompts 2026-08-05 14:42:08 +08:00
Brendan Allan 2893d3f55c test(app): finalize v2-only e2e expectations 2026-08-05 14:41:59 +08:00
Brendan Allan aee34c9383 refactor(app): remove v1 compatibility 2026-08-05 14:41:59 +08:00
Brendan Allan 5d351406a1 fix(app): translate legacy question tool IDs 2026-08-05 14:41:35 +08:00
Brendan Allan 86d90eb4ef test(app): retain gated legacy projection coverage 2026-08-05 14:35:05 +08:00
Brendan Allan 31d1fae1e7 refactor(app): gate unsupported v2 capabilities 2026-08-05 14:35:05 +08:00
422 changed files with 23629 additions and 15991 deletions
+14 -1
View File
@@ -111,7 +111,6 @@
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"happy-dom": "20.11.1",
"tw-animate-css": "1.4.0", "tw-animate-css": "1.4.0",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
@@ -489,6 +488,18 @@
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
}, },
}, },
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.8",
"dependencies": {
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.18.8", "version": "1.18.8",
@@ -2055,6 +2066,8 @@
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"], "@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
"@opencode-ai/effect-sqlite-node": ["@opencode-ai/effect-sqlite-node@workspace:packages/effect-sqlite-node"],
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"], "@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
+26 -206
View File
@@ -5,27 +5,8 @@
- Use the `dev` branch database schema and migration registry as the V1 baseline. - Use the `dev` branch database schema and migration registry as the V1 baseline.
- Remove migrations that exist only on the V2 branch. - Remove migrations that exist only on the V2 branch.
- Generate one canonical migration from the `dev` schema to the final V2 schema. - Generate one canonical migration from the `dev` schema to the final V2 schema.
- Keep the canonical migration focused on schema changes and dropping obsolete tables. - Add explicit data operations to that migration where generated DDL is insufficient.
- Run the V1 history backfill through an experimental server endpoint invoked by the CLI before it opens the TUI. - Test the migration against a populated database at the exact `dev` schema.
- Show committed session progress while the endpoint runs.
Expose `GET /api/experimental/migration/v1` for status and a blocking `POST /api/experimental/migration/v1` to run or
resume the backfill. The status is `required`, `running`, or `completed`. On startup, the CLI checks status first and
renders no migration UI when it is already complete. For required or running status, it shows a spinner and waits for the
blocking POST without a request timeout. While migration runs, poll GET once per second and render completed and total
session counts. GET derives total from all session rows and completed from rows through the stored cursor; the count
advances only after a session transaction commits. The POST returns `{ status: "completed" }`. Do not add a background
job or streaming progress protocol. Interrupted calls resume from the stored cursor.
Initially, only interactive TUI startup performs this check; noninteractive run, ACP, raw API, service, health, version,
and help flows do not trigger the backfill.
Keep migration behavior in Core: status, semaphore, checkpointing, V1 decoding, transformation, and database writes.
Protocol owns the experimental GET/POST contracts, Server handlers delegate to Core, and the interactive CLI owns only
the status check and spinner presentation.
Guard the endpoint with one process-local Effect `Semaphore`. Concurrent callers wait; after the active call completes,
waiting callers acquire the permit, observe the completion key, and return immediately. No distributed lock is required
for the current single elected server process.
## Preserve ## Preserve
@@ -34,35 +15,20 @@ The canonical V1 data remains in its existing tables. In particular, preserve `s
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
workspace relationships. workspace relationships.
Preserve existing non-null `session.agent` and `session.model` selections. Fill missing values from the latest ordinary Keep the `todo` table and its data unchanged. V2 does not currently migrate todos into another representation, and the
V1 user message ordered by `time_created` and `id`, excluding compaction and subtask-only messages. Copy agent, provider generated migration must not drop the table.
ID, model ID, and variant, normalizing an absent variant to `default`.
Recompute session usage aggregates from all canonical V1 assistant messages, including compaction or other internal ## Truncate
assistants omitted from the V2 projection. Overwrite session cost and input, output, reasoning, cache-read, and
cache-write token totals with those sums.
Clear persisted `session.revert` state. A staged revert is transient operational state and may refer to omitted projection Truncate these pre-launch V2 tables before applying schema changes:
rows or unavailable snapshots; it must not resume automatically after upgrading. Preserve the underlying messages,
parts, and file history.
Clear `session.time_compacting`, leave the new `time_suspended` column as `NULL`, and preserve session creation, update, - `event`
and archive timestamps. Preserve project `time_initialized`; it is unrelated durable state. - `event_sequence`
- `session_message`
Keep the legacy `todo` table and its data physically unchanged, but do not include it in the final V2 Drizzle schema. These rows are not canonical V1 data. Truncating `event` before adding the required `event.created` column means the
After generation, remove the generated `DROP TABLE todo` statement from the canonical migration so the table remains as column needs neither a backfill nor a default. After truncation, rebuild `session_message` from canonical V1 `message`
unmanaged legacy storage. and `part` rows rather than retaining its pre-launch V2 contents.
## Per-Session Replacement
Do not truncate `event`, `event_sequence`, or `session_message` globally before the backfill. A whole-table delete can
hold SQLite's writer lock long enough to block the running TUI.
Replace each legacy session's V2 state inside that session's checkpointed migration transaction. Delete `event` rows for
the session aggregate, delete its `session_message` rows, rebuild its projection from canonical V1 `message` and `part`
rows, and overwrite its `event_sequence` watermark. If migration of that session fails, all replacements roll back and
the durable cursor remains at the previously committed session. Rows owned by sessions outside the legacy migration set
remain untouched.
## Message Backfill ## Message Backfill
@@ -70,23 +36,9 @@ Backfill canonical V1 history from `message` and `part` into `session_message`.
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
V2 session APIs, which read `session_message`. V2 session APIs, which read `session_message`.
Do not fail the whole migration when a V1 message or part payload cannot be decoded. Skip an undecodable message's V2
projection and log its session and message IDs. Skip an undecodable part while continuing to map its message, and perform
special-message pairing only with decoded rows. Assign sequences after filtering. Leave every malformed source row
untouched in the V1 tables.
Skip and log orphan parts whose source message does not exist and parts with unknown or unsupported types. Continue
migrating the owning message and other valid parts. Include session, message, part ID, and observed type in warnings, and
leave skipped source rows unchanged.
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
avoid rewriting other persisted state that may refer to a message. avoid rewriting other persisted state that may refer to a message.
For ordinary user and assistant rows, preserve source `message.time_created` and `message.time_updated`. Entirely
synthetic messages preserve their source timestamps, and synthetic rows split from mixed messages use the source user
timestamps. A collapsed compaction uses the compaction user creation time and the later update time of the compaction
user and summary assistant. Keep payload creation/completion times consistent with row timestamps.
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
contiguous `session_message.seq` values starting at `0`. contiguous `session_message.seq` values starting at `0`.
@@ -94,122 +46,15 @@ Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becom
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2 V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
payload. payload.
Keep ordinary messages even when their transformed payload becomes empty after filtering. Preserve an empty V2 user row
with `text: ""` and an empty V2 assistant row with `content: []` so IDs, chronology, and conversation structure remain
stable. Omit only explicitly dropped internal concepts and undecodable messages.
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special `compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
part mappings must be decided explicitly before implementing the backfill. part mappings must be decided explicitly before implementing the backfill.
Do not carry the V1 subtask concept into the V2 projection. Omit user messages containing only `subtask` parts and omit
the paired assistant task-tool messages generated from those markers. For mixed user messages, ignore the `subtask`
parts while preserving ordinary content, and still omit assistant task-tool messages generated by the skipped subtasks.
Keep all source rows unchanged in the V1 `message` and `part` tables.
Map ordinary V1 assistant `text` and `reasoning` parts into the V2 assistant `content` array in part order. Preserve text,
including empty assistant text parts used as structural separators. Map V1 part metadata to optional V2 provider state.
For reasoning, map `time.start` to `time.created` and optional `time.end` to `time.completed`.
Preserve V1 tool parts that are `pending` or `running`, but convert them to terminal V2 tool error states. Preserve the
call ID, tool name, parsed input, metadata, and available start time. Use the assistant message creation time when the V1
state has no start time. Set the error to type `tool.interrupted` with message
`Tool execution was interrupted before V2 migration`. Never resume migrated tool executions.
For a completed V1 tool part, use `callID` as the V2 tool content ID and preserve the tool name and parsed input. Set the
state to `completed`. Convert V1 output into the first text content item and convert stored output attachments into
following file content items with their URI, MIME type, and filename. Preserve state metadata. Map `time.start` to
`time.created` and `time.end` to `time.completed`. When `time.compacted` exists, use
`[Old tool result content cleared]` as the only output and omit attachments.
For a failed V1 tool part, preserve the call ID, tool name, parsed input, metadata, and timestamps, and set the V2 state
to `error`. Convert the V1 error string to a structured error with type `tool.execution`. If V1 metadata contains a string
`output`, preserve it as optional V2 text content. Map `time.start` to `time.created` and `time.end` to `time.completed`.
For an ordinary V1 assistant message, preserve agent, provider ID, model ID, optional variant, creation and completion
times, cost, and input/output/reasoning/cache token counts. Use `default` when the V1 variant is absent. Ignore V1
`tokens.total` because it is derivable and V2 does not persist it.
Use V1 assistant `parentID` only while pairing compactions and skipped subtasks with their originating user messages. Do
not persist it in ordinary V2 assistant rows; V2 uses ordered history rather than user/assistant parent links.
Ignore the optional V1 assistant `structured` output value. V2 has no equivalent top-level assistant field, and visible
text and tool content are migrated separately. Retain the original structured value only in the V1 `message` row.
Ignore V1 assistant `mode` and historical `path` (`cwd` and `root`). Mode is redundant with the preserved assistant
agent, and historical filesystem paths do not belong to the V2 assistant message contract. Retain them only in the V1
`message` row.
For assistant finish reasons, preserve `stop`, `length`, `tool-calls`, `content-filter`, `error`, and `unknown`. Map every
other nonempty V1 finish value to `unknown`, and leave the field absent when V1 omitted it. Do not retain unrecognized raw
finish values in metadata.
Map V1 assistant errors into the current V2 `{ type, message }` storage shape. Normalize Auth, content-filter, context
overflow, structured-output, output-length, aborted, API, and unknown errors to the established V2 string conventions,
preserve the message, and discard V1-only retryability and raw provider details.
Ignore V1 `retry` parts. Do not populate the V2 assistant `retry` field during migration; historical retry state is not
useful enough to preserve. The original retry rows remain in the V1 `part` table.
Do not emit V2 assistant content for V1 `step-start` and `step-finish` parts. Use the first available
`step-start.snapshot` as `assistant.snapshot.start` and the last available `step-finish.snapshot` as
`assistant.snapshot.end`. Continue to source finish, cost, and tokens from the assistant message itself. Ignore step
markers without snapshots.
Do not emit assistant content for standalone V1 `snapshot` or `patch` parts. If no start snapshot came from `step-start`,
use the first standalone snapshot value, then the first patch hash as a final fallback. Only `step-finish.snapshot` may
populate the end snapshot. Merge patch file lists into `assistant.snapshot.files` in first-seen order with duplicates
removed.
V2 follow-up: replace the open `SessionError.Error` string shape with a properly typed persisted error union. This is not
a blocker for the V1 migration, which should target the current storage contract.
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior. adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
For an ordinary V2 user message, take visible V1 text parts that are neither ignored nor synthetic, preserve part order,
and join their text with `"\n\n"`. Use an empty string when the message contains attachments but no ordinary text.
Ignore the optional V1 user-message `system` override. Do not create a V2 system message or preserve the override in
metadata. The original value remains in the V1 `message` row.
Ignore the optional V1 user-message `tools` map. It represented request-time tool enablement for a historical step and
must not affect future V2 execution. The original value remains in the V1 `message` row.
Ignore the optional V1 user-message `format` field and its schema. It controlled structured-output behavior for a
historical request and must not affect future V2 runs. Preserve visible assistant text normally; retain the original
format only in the V1 `message` row.
Ignore V1 user-message `summary` metadata, including title, body, and diffs. V2 user messages have no equivalent field,
and session-level summary data is already persisted separately. Retain the original summary only in the V1 `message`
row.
Map V1 `agent` parts into the V2 user message's `agents` array in part order. Preserve `name`. When the V1 part has
`source`, map its `value`, `start`, and `end` into the V2 attachment's `mention.text`, `mention.start`, and `mention.end`.
Omit `agents` when there are no agent parts.
Do not read the filesystem or network while migrating V1 file attachments. Attachment migration must be deterministic
from database contents alone. Convert persisted `data:` URLs; represent non-embedded `file:`, HTTP, and other external
URLs with deterministic text rather than fetching them. Keep the original V1 `part` rows unchanged.
For a V1 file backed by a `data:` URL, decode the URL and normalize its payload to base64 for the V2 attachment's `data`.
Preserve `mime` and optional `filename` as `name`. Use a V2 `uri` source with the original URI for a V1 resource source;
otherwise use an `inline` source. When V1 source text metadata exists, map its `value`, `start`, and `end` into the V2
attachment mention. Leave `description` unset and preserve file-part order in the V2 `files` array.
For a non-embedded V1 file, do not create a V2 file attachment. Append
`[Attachment unavailable after migration: <name-or-url> (<mime>)]` to the V2 user text in original part order, separated
by blank lines. Prefer the V1 filename, then resource URI, then part URL for the label. The original URL remains only in
the preserved V1 `part` row.
For a synthetic row split from a mixed user message, derive a generated-looking ID from the source message ID. Preserve
the source ID's 12-character timestamp component and replace its 14-character random component with a deterministic
base-62 encoding of a hash of `v1-synthetic:` plus the source message ID. If that candidate collides with an existing or
derived message ID, deterministically retry with an incrementing salt. Place the synthetic row immediately after its
source user row. Entirely synthetic messages continue to reuse their original message ID.
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
admitted compaction input ID and preserves references to the initiating message. admitted compaction input ID and preserves references to the initiating message.
@@ -219,13 +64,9 @@ serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2 retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
assistant row. assistant row.
Do not project incomplete or failed V1 compactions into `session_message`. Omit both the internal compaction user marker After rebuilding `session_message`, seed `event_sequence` with one row per migrated session. Set its watermark to that
and its paired summary assistant when no successful summary was completed. Assign final sequence numbers after filtering session's maximum backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting
so omitted compactions leave no gaps. Their source rows remain preserved in the V1 `message` and `part` tables. before migrated history. The `event` table remains empty.
After rebuilding a session's `session_message`, replace its `event_sequence` watermark with that session's maximum
backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting before migrated
history. The migrated session's prior `event` rows are removed in the same transaction.
## Drop ## Drop
@@ -233,7 +74,6 @@ Drop these pre-launch V2 tables without preserving or transforming their rows:
- `session_input` - `session_input`
- `session_context_epoch` - `session_context_epoch`
- `data_migration`
Do not transfer `session_input` rows into `session_pending`. Do not transfer `session_input` rows into `session_pending`.
@@ -263,36 +103,16 @@ schema.
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns. backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
## Execution ## Verification
Before transforming V1 rows, look for `opencode-next.db` in the data directory. This file was used by pre-launch V2 The canonical migration test should seed representative V1 sessions, messages, parts, todos, projects, accounts,
builds. Open it read-only with Bun SQLite and copy its `project`, `session`, and `session_message` rows directly into the credentials, permissions, shares, and workspaces. After migration, it should verify:
current `project`, `session_v2`, and `session_message` tables. Existing current projects and Sessions win ID collisions.
Do not copy its durable events or runtime caches; initialize each imported Session's `event_sequence` watermark from its
maximum message sequence. Commit each imported Session independently and leave the source database untouched.
The previous V2 import is part of this migration and uses the same completion marker. It needs no source-specific cursor: - Preserved rows and encoded values remain unchanged.
the destination Session row is the per-Session idempotency boundary, so a retry skips transactions that already committed. - Todo rows remain available in the unchanged `todo` table.
- `event` is empty, and stale pre-launch rows are absent from the rebuilt projections.
Store V1 backfill state in `kv`; do not retain a dedicated `data_migration` table. Store the last successfully migrated - Backfilled `session_message` rows represent the canonical V1 `message` and `part` history.
session ID under `migration.v1-v2.session.cursor` and write `migration.v1-v2.completed` with value `true` after every - Each migrated session's `event_sequence` watermark matches its maximum backfilled message sequence.
session finishes. Delete the cursor key on completion and return immediately on later calls when the completion key - Dropped tables no longer exist.
exists. - New tables exist and are empty.
- The final schema has no ungenerated changes.
Absence of the completion key means migration is required, including on a fresh database. Running the endpoint against a
database with no sessions completes immediately and writes the completion key; fresh database initialization does not
seed migration state specially.
Process sessions in stable ID order. Rebuild one session in one transaction, including its `session_message` rows,
session-level backfills, `event_sequence` watermark, and cursor update. If interrupted during a session, that transaction
rolls back and the next endpoint call retries the same session. If it committed, the next call continues after the stored
cursor. Mark the migration complete after the final session and return immediately on later calls.
Ensure the global project exists using the current platform's filesystem root as its worktree. Process every `session`
row, including archived, root, child, and empty sessions, as well as sessions whose messages are all skipped or internal.
Reassign beta and V1 Sessions whose referenced project row is missing to the global project and log a warning. Each
successfully committed session advances the cursor.
## Testing
Detailed migration test design is deferred until after the canonical migration is implemented.
+33 -97
View File
@@ -67,12 +67,6 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
}) })
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall> type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
// Intentionally omit Gemini's provider-specific `extra_content.google.thought_signature`
// extension until direct Google OpenAI-compatible routing is supported here:
// https://github.com/vercel/ai/issues/11590
// https://github.com/vercel/ai/pull/11745
// https://ai.google.dev/gemini-api/docs/thought-signatures#openai
const OpenAIChatUserContent = Schema.Union([ const OpenAIChatUserContent = Schema.Union([
Schema.Struct({ Schema.Struct({
type: Schema.Literal("text"), type: Schema.Literal("text"),
@@ -151,33 +145,22 @@ export type OpenAIChatBody = Schema.Schema.Type<typeof OpenAIChatBody>
// The event schema is one decoded SSE `data:` payload. `Framing.sse` splits the // The event schema is one decoded SSE `data:` payload. `Framing.sse` splits the
// byte stream into strings, then `Protocol.jsonEvent` decodes each string into // byte stream into strings, then `Protocol.jsonEvent` decodes each string into
// this provider-native event shape. // this provider-native event shape.
const OpenAIChatUsage = Schema.StructWithRest( const OpenAIChatUsage = Schema.Struct({
Schema.Struct({ prompt_tokens: Schema.optional(Schema.Number),
prompt_tokens: optionalNull(Schema.Number), completion_tokens: Schema.optional(Schema.Number),
completion_tokens: optionalNull(Schema.Number), total_tokens: Schema.optional(Schema.Number),
total_tokens: optionalNull(Schema.Number), prompt_tokens_details: optionalNull(
prompt_tokens_details: optionalNull( Schema.Struct({
Schema.StructWithRest( cached_tokens: Schema.optional(Schema.Number),
Schema.Struct({ cache_write_tokens: Schema.optional(Schema.Number),
cached_tokens: optionalNull(Schema.Number), }),
cache_write_tokens: optionalNull(Schema.Number), ),
}), completion_tokens_details: optionalNull(
[Schema.Record(Schema.String, Schema.Unknown)], Schema.Struct({
), reasoning_tokens: Schema.optional(Schema.Number),
), }),
completion_tokens_details: optionalNull( ),
Schema.StructWithRest( })
Schema.Struct({
reasoning_tokens: optionalNull(Schema.Number),
accepted_prediction_tokens: optionalNull(Schema.Number),
rejected_prediction_tokens: optionalNull(Schema.Number),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatToolCallDeltaFunction = Schema.Struct({ const OpenAIChatToolCallDeltaFunction = Schema.Struct({
name: optionalNull(Schema.String), name: optionalNull(Schema.String),
@@ -185,7 +168,7 @@ const OpenAIChatToolCallDeltaFunction = Schema.Struct({
}) })
const OpenAIChatToolCallDelta = Schema.Struct({ const OpenAIChatToolCallDelta = Schema.Struct({
index: optionalNull(Schema.Number), index: Schema.Number,
id: optionalNull(Schema.String), id: optionalNull(Schema.String),
function: optionalNull(OpenAIChatToolCallDeltaFunction), function: optionalNull(OpenAIChatToolCallDeltaFunction),
}) })
@@ -239,8 +222,6 @@ export interface ParserState {
readonly reasoningDetails: Array<unknown> readonly reasoningDetails: Array<unknown>
readonly reasoningDetailsObserved: boolean readonly reasoningDetailsObserved: boolean
readonly reasoningEmitted: boolean readonly reasoningEmitted: boolean
readonly latestToolIndex?: number
readonly nextToolIndex: number
} }
// ============================================================================= // =============================================================================
@@ -578,34 +559,22 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// satisfied on both sides. // satisfied on both sides.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined if (!usage) return undefined
const input = usage.prompt_tokens ?? undefined const cached = usage.prompt_tokens_details?.cached_tokens
const output = usage.completion_tokens ?? undefined const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens
const cached = usage.prompt_tokens_details?.cached_tokens ?? undefined const reasoning = usage.completion_tokens_details?.reasoning_tokens
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, ProviderShared.sumTokens(cached, cacheWrite))
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
return new Usage({ return new Usage({
inputTokens: input, inputTokens: usage.prompt_tokens,
outputTokens: output, outputTokens: usage.completion_tokens,
nonCachedInputTokens: nonCached, nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached, cacheReadInputTokens: cached,
cacheWriteInputTokens: cacheWrite, cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning, reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined), totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
providerMetadata: { openai: usage }, providerMetadata: { openai: usage },
}) })
} }
const toolIndexByID = (
tools: ParserState["tools"],
pendingTools: ParserState["pendingTools"],
id: string | undefined,
) => {
if (!id) return undefined
const entry = Object.entries({ ...pendingTools, ...tools }).find(([, tool]) => tool?.id === id)
return entry ? Number(entry[0]) : undefined
}
const reasoningDelta = ( const reasoningDelta = (
delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined, delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined,
configuredField?: string, configuredField?: string,
@@ -688,34 +657,17 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const usage = mapUsage(event.usage) ?? state.usage const usage = mapUsage(event.usage) ?? state.usage
const choice = event.choices?.[0] const choice = event.choices?.[0]
const rawFinishReason = choice?.finish_reason const finishReason = choice?.finish_reason
const finishReason = ? { normalized: mapFinishReason(choice.finish_reason), raw: choice.native_finish_reason ?? choice.finish_reason }
rawFinishReason !== undefined && rawFinishReason !== null : state.finishReason
? { normalized: mapFinishReason(rawFinishReason), raw: choice?.native_finish_reason ?? rawFinishReason }
: state.finishReason
const delta = choice?.delta const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? [] const toolDeltas = delta?.tool_calls ?? []
let tools = state.tools let tools = state.tools
let pendingTools = state.pendingTools let pendingTools = state.pendingTools
let latestToolIndex = state.latestToolIndex
let nextToolIndex = state.nextToolIndex
let lifecycle = state.lifecycle let lifecycle = state.lifecycle
const reasoning = reasoningDelta(delta, state.reasoningField) const reasoning = reasoningDelta(delta, state.reasoningField)
const hasLateContent =
Boolean(delta?.content) ||
reasoning !== undefined ||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
toolDeltas.some(
(tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments),
)
if (state.finishReason !== undefined) {
if (hasLateContent)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
return [{ ...state, usage }, events] as const
}
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined) const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta) if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
@@ -742,37 +694,24 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
} }
// Compatible providers may omit indexes. Prefer durable identity, then use for (const tool of toolDeltas) {
// batch position for parallel deltas or the latest call for sparse chunks. const current = tools[tool.index]
for (const [position, tool] of toolDeltas.entries()) { const pending = pendingTools[tool.index]
const matched = toolIndexByID(tools, pendingTools, tool.id || undefined)
const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position)
const fallbackTool = tools[fallback] ?? pendingTools[fallback]
const index =
tool.index ?? matched ??
(tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
const current = tools[index]
const pending = pendingTools[index]
const id = current?.id ?? pending?.id ?? (tool.id || undefined) const id = current?.id ?? pending?.id ?? (tool.id || undefined)
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined) const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}` const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
latestToolIndex = index
nextToolIndex = Math.max(nextToolIndex, index + 1)
if (!current && (!id || !name)) { if (!current && (!id || !name)) {
pendingTools = { pendingTools = { ...pendingTools, [tool.index]: { id: id || undefined, name: name || undefined, input: text } }
...pendingTools,
[index]: { id: id || undefined, name: name || undefined, input: text },
}
continue continue
} }
if (pending) { if (pending) {
pendingTools = { ...pendingTools } pendingTools = { ...pendingTools }
delete pendingTools[index] delete pendingTools[tool.index]
} }
const result = ToolStream.appendOrStart( const result = ToolStream.appendOrStart(
ADAPTER, ADAPTER,
tools, tools,
index, tool.index,
{ id: id || undefined, name: name || undefined, text }, { id: id || undefined, name: name || undefined, text },
"OpenAI Chat tool call delta is missing id or name", "OpenAI Chat tool call delta is missing id or name",
) )
@@ -804,8 +743,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
reasoningDetails: state.reasoningDetails, reasoningDetails: state.reasoningDetails,
reasoningDetailsObserved, reasoningDetailsObserved,
reasoningEmitted, reasoningEmitted,
latestToolIndex,
nextToolIndex,
}, },
events, events,
] as const ] as const
@@ -862,7 +799,6 @@ export const protocol = Protocol.make({
reasoningDetails: [], reasoningDetails: [],
reasoningDetailsObserved: false, reasoningDetailsObserved: false,
reasoningEmitted: false, reasoningEmitted: false,
nextToolIndex: 0,
}), }),
step, step,
onHalt: finishEvents, onHalt: finishEvents,
+23 -22
View File
@@ -5,7 +5,6 @@ import type { ProviderPackage } from "../provider-package"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses" import * as OpenAIResponses from "../protocols/openai-responses"
import { ProviderShared } from "../protocols/shared"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("azure") export const id = ProviderID.make("azure")
@@ -20,7 +19,7 @@ export type LanguageModelOptions = AzureURL &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly apiVersion?: string readonly apiVersion?: string
readonly queryParams?: Record<string, string> readonly queryParams?: Record<string, string>
readonly useDeploymentBasedUrls?: boolean readonly useCompletionUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput readonly providerOptions?: OpenAIProviderOptionsInput
} }
export type Config = LanguageModelOptions export type Config = LanguageModelOptions
@@ -30,22 +29,27 @@ export type Settings = ProviderPackage.Settings &
readonly apiKey?: string readonly apiKey?: string
readonly apiVersion?: string readonly apiVersion?: string
readonly queryParams?: Readonly<Record<string, string>> readonly queryParams?: Readonly<Record<string, string>>
readonly useDeploymentBasedUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput readonly providerOptions?: OpenAIProviderOptionsInput
} }
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai` const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1`
const responsesRoute = OpenAIResponses.route.with({ const responsesRoute = OpenAIResponses.route.with({
id: "azure-openai-responses", id: "azure-openai-responses",
provider: id, provider: id,
auth: routeAuth, auth: routeAuth,
endpoint: {
query: { "api-version": "v1" },
},
}) })
const chatRoute = OpenAIChat.route.with({ const chatRoute = OpenAIChat.route.with({
id: "azure-openai-chat", id: "azure-openai-chat",
provider: id, provider: id,
auth: routeAuth, auth: routeAuth,
endpoint: {
query: { "api-version": "v1" },
},
}) })
export const routes = [responsesRoute, chatRoute] export const routes = [responsesRoute, chatRoute]
@@ -55,7 +59,7 @@ const defaults = (input: Config) => {
apiKey: _, apiKey: _,
apiVersion: _apiVersion, apiVersion: _apiVersion,
resourceName: _resourceName, resourceName: _resourceName,
useDeploymentBasedUrls: _useDeploymentBasedUrls, useCompletionUrls: _useCompletionUrls,
baseURL: _baseURL, baseURL: _baseURL,
queryParams: _queryParams, queryParams: _queryParams,
...rest ...rest
@@ -76,39 +80,37 @@ const auth = (input: Config) => {
) )
} }
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config, modelID: string | ModelID) => const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) =>
route.with({ route.with({
auth: auth(input), auth: auth(input),
endpoint: endpoint(input, modelID), endpoint: {
// AtLeastOne guarantees at least one is set; baseURL wins if both are.
baseURL: input.baseURL ?? resourceBaseURL(input.resourceName!),
query: {
...(input.apiVersion ? { "api-version": input.apiVersion } : {}),
...input.queryParams,
},
},
}) })
function endpoint(input: Config, modelID: string | ModelID) {
const baseURL = ProviderShared.trimBaseUrl(input.baseURL ?? resourceBaseURL(input.resourceName!))
const query = { "api-version": input.apiVersion ?? "v1", ...input.queryParams }
if (input.useDeploymentBasedUrls) return { baseURL: `${baseURL}/deployments/${modelID}`, query }
if (input.baseURL !== undefined && !new URL(input.baseURL).hostname.endsWith(".openai.azure.com")) {
return { baseURL, query: input.queryParams }
}
return { baseURL: `${baseURL}/v1`, query }
}
export const configure = (input: Config) => { export const configure = (input: Config) => {
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input) const modelDefaults = defaults(input)
const responses = (modelID: string | ModelID) => const responses = (modelID: string | ModelID) =>
configuredRoute(responsesRoute, input, modelID) configuredResponsesRoute
.with(withOpenAIOptions(modelID, modelDefaults)) .with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID }) .model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => const chat = (modelID: string | ModelID) =>
configuredRoute(chatRoute, input, modelID) configuredChatRoute
.with(withOpenAIOptions(modelID, modelDefaults)) .with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID }) .model<OpenAIProviderOptionsInput>({ id: modelID })
return { return {
id, id,
model: responses, model: (modelID: string | ModelID) => (input.useCompletionUrls === true ? chat(modelID) : responses(modelID)),
responses, responses,
chat, chat,
configure, configure,
@@ -129,7 +131,6 @@ const config = (settings: Settings): Config => {
limits: settings.limits, limits: settings.limits,
providerOptions: settings.providerOptions, providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
} }
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL } if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName } if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
@@ -15,7 +15,8 @@ export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
const VERSION = "vertex-2023-10-16" as const const VERSION = "vertex-2023-10-16" as const
export const id = ProviderID.make("google-vertex") // models.dev uses this provider id even though the API contract is Anthropic Messages.
export const id = ProviderID.make("google-vertex-anthropic")
export type Config = RouteDefaultsInput & export type Config = RouteDefaultsInput &
GoogleVertexShared.OAuthOptions & { GoogleVertexShared.OAuthOptions & {
-1
View File
@@ -178,7 +178,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility), toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String), reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility), maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
}) {} }) {}
export namespace LanguageModelCompatibility { export namespace LanguageModelCompatibility {
+2 -2
View File
@@ -102,7 +102,7 @@ describe("llm constructors", () => {
const updated = LanguageModel.update(base, { const updated = LanguageModel.update(base, {
route: responsesRoute, route: responsesRoute,
defaults: { generation: { maxTokens: 20 } }, defaults: { generation: { maxTokens: 20 } },
compatibility: { toolSchema: "gemini", requireFinishReason: false }, compatibility: { toolSchema: "gemini" },
}) })
const updatedInput = LanguageModel.input(updated) const updatedInput = LanguageModel.input(updated)
@@ -110,7 +110,7 @@ describe("llm constructors", () => {
expect(String(updated.id)).toBe("fake-model") expect(String(updated.id)).toBe("fake-model")
expect(updated.route).toBe(responsesRoute) expect(updated.route).toBe(responsesRoute)
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 }) expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
expect(updated.compatibility).toEqual({ toolSchema: "gemini", requireFinishReason: false }) expect(updated.compatibility).toEqual({ toolSchema: "gemini" })
expect(updatedInput.defaults).toBe(updated.defaults) expect(updatedInput.defaults).toBe(updated.defaults)
expect(updatedInput.compatibility).toBe(updated.compatibility) expect(updatedInput.compatibility).toBe(updated.compatibility)
expect(String(updatedInput.provider)).toBe("fake") expect(String(updatedInput.provider)).toBe("fake")
-21
View File
@@ -209,27 +209,6 @@ describe("provider package entrypoints", () => {
expect(chat.route.id).toBe("azure-openai-chat") expect(chat.route.id).toBe("azure-openai-chat")
}) })
test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => {
const Azure = await import("@opencode-ai/ai/providers/azure")
const deployment = Azure.model("custom-deployment", {
apiKey: "fixture",
resourceName: "opencode-test",
apiVersion: "2025-01-01-preview",
useDeploymentBasedUrls: true,
})
const gateway = Azure.model("gateway-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/azure/",
})
expect(deployment.route.endpoint).toMatchObject({
baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment",
query: { "api-version": "2025-01-01-preview" },
})
expect(gateway.route.endpoint.baseURL).toBe("https://gateway.example/azure")
expect(gateway.route.endpoint.query).toBeUndefined()
})
test("maps Google package settings onto the Gemini model", async () => { test("maps Google package settings onto the Gemini model", async () => {
const Google = await import("@opencode-ai/ai/providers/google") const Google = await import("@opencode-ai/ai/providers/google")
const selected = Google.model("gemini-2.5-flash", { const selected = Google.model("gemini-2.5-flash", {
@@ -56,14 +56,13 @@ describe("Google Vertex providers", () => {
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () => it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
Effect.gen(function* () { Effect.gen(function* () {
const model = GoogleVertexMessages.configure({
accessToken: "vertex-token",
location: "eu",
project: "vertex-project",
}).model("claude-sonnet-4-6")
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLM.request({ LLM.request({
model, model: GoogleVertexMessages.configure({
accessToken: "vertex-token",
location: "eu",
project: "vertex-project",
}).model("claude-sonnet-4-6"),
prompt: "Say hello.", prompt: "Say hello.",
}), }),
).pipe( ).pipe(
@@ -98,7 +97,6 @@ describe("Google Vertex providers", () => {
), ),
) )
expect(model.provider).toBe("google-vertex")
expect(response.text).toBe("Hello.") expect(response.text).toBe("Hello.")
}), }),
) )
@@ -7,7 +7,7 @@ import { compileRequest } from "../../src/route/client"
import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat" import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
import { it } from "../lib/effect" import { it } from "../lib/effect"
import { dynamicResponse, fixedResponse } from "../lib/http" import { dynamicResponse } from "../lib/http"
import { sseEvents } from "../lib/sse" import { sseEvents } from "../lib/sse"
const Json = Schema.fromJsonString(Schema.Unknown) const Json = Schema.fromJsonString(Schema.Unknown)
@@ -253,106 +253,4 @@ describe("OpenAI-compatible Chat route", () => {
}) })
}), }),
) )
it.effect("accepts nullable usage and preserves provider fields", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ content: "Hello" }),
deltaChunk({}, "stop"),
usageChunk({
prompt_tokens: null,
completion_tokens: null,
total_tokens: null,
prompt_tokens_details: { cached_tokens: null, vendor_cache_tokens: 3 },
completion_tokens_details: {
reasoning_tokens: null,
accepted_prediction_tokens: null,
rejected_prediction_tokens: null,
},
cost: "0.001",
}),
),
),
),
)
expect(response.usage).toMatchObject({
inputTokens: undefined,
outputTokens: undefined,
totalTokens: undefined,
providerMetadata: {
openai: {
prompt_tokens: null,
completion_tokens: null,
total_tokens: null,
prompt_tokens_details: { cached_tokens: null, vendor_cache_tokens: 3 },
cost: "0.001",
},
},
})
}),
)
it.effect("assembles indexless parallel tool calls across sparse chunks", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } })],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({
tool_calls: [
{ id: "call_paris", function: { name: "weather", arguments: '{"city":"' } },
{ index: null, id: "call_london", function: { name: "weather", arguments: '{"city":"' } },
],
}),
deltaChunk({ tool_calls: [{ function: { arguments: 'London"}' } }] }),
deltaChunk({ tool_calls: [{ id: "call_paris", function: { arguments: 'Paris"}' } }] }),
deltaChunk({}, "tool_calls"),
),
),
),
)
expect(response.toolCalls).toMatchObject([
{ id: "call_paris", name: "weather", input: { city: "Paris" } },
{ id: "call_london", name: "weather", input: { city: "London" } },
])
}),
)
it.effect("treats an empty finish reason as terminal", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "")))),
)
expect(response.finishReason).toEqual({ normalized: "unknown", raw: "" })
}),
)
it.effect("rejects content after a terminal chunk", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ content: "Hello" }),
deltaChunk({}, "stop"),
deltaChunk({ tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{}" } }] }),
),
),
),
Effect.flip,
)
expect(error.message).toContain("OpenAI Chat received content after the finish reason")
}),
)
}) })
@@ -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 { SessionInfo, SessionStatus } from "@opencode-ai/client/promise" import type { SessionV1Info, 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 = SessionInfo type Session = SessionV1Info
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,
location: { directory }, directory,
title, title,
cost: 0, version: "dev",
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1700000000000, updated: 1700000000000 }, time: { created: 1700000000000, updated: 1700000000000 },
...input, ...input,
} }
@@ -177,8 +177,7 @@ test("shows all and expands historical diff summary without overlap", async ({ p
const firstUser = userMessage(undefined, { const firstUser = userMessage(undefined, {
summary: { summary: {
diffs: Array.from({ length: 12 }, (_, index) => ({ diffs: Array.from({ length: 12 }, (_, index) => ({
file: `src/diff-${index}.ts`, file: `src/diff-${index}.ts`,
status: "modified",
additions: 1, additions: 1,
deletions: 1, deletions: 1,
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`, patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
@@ -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: followingTextID, id: "prt_0104_text",
sessionID, sessionID,
messageID: assistantID, messageID: assistantID,
type: "text", type: "text",
-1
View File
@@ -40,7 +40,6 @@
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"happy-dom": "20.11.1",
"tw-animate-css": "1.4.0", "tw-animate-css": "1.4.0",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
+1 -1
View File
@@ -155,7 +155,7 @@ function LegacyTargetSessionRedirect() {
) )
createEffect(() => { createEffect(() => {
const directory = current()?.session.location.directory const directory = current()?.session.directory
if (!directory) return if (!directory) return
navigate(legacySessionHref(directory, params.id), { replace: true }) navigate(legacySessionHref(directory, params.id), { replace: true })
}) })
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -14,6 +14,7 @@ import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers" import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { normalizeSessionInfo } from "@/utils/session"
export type CommandPaletteEntry = { export type CommandPaletteEntry = {
id: string id: string
@@ -256,6 +257,7 @@ export function createServerSessionEntries(props: {
.load(search, current.signal) .load(search, current.signal)
.then((result) => .then((result) =>
result.data result.data
.map(normalizeSessionInfo)
.filter((session) => !session.time.archived) .filter((session) => !session.time.archived)
.map((session) => { .map((session) => {
const project = const project =
@@ -264,9 +266,9 @@ export function createServerSessionEntries(props: {
id: `session:${props.server}:${session.id}`, id: `session:${props.server}:${session.id}`,
type: "session" as const, type: "session" as const,
title: session.title || props.untitled(), title: session.title || props.untitled(),
description: project ? displayName(project) : getFilename(session.location.directory), description: project ? displayName(project) : getFilename(session.directory),
category: props.category(), category: props.category(),
directory: session.location.directory, directory: session.directory,
sessionID: session.id, sessionID: session.id,
server: props.server, server: props.server,
project, project,
@@ -10,6 +10,7 @@ const statusLabels = {
connected: "mcp.status.connected", connected: "mcp.status.connected",
failed: "mcp.status.failed", failed: "mcp.status.failed",
needs_auth: "mcp.status.needs_auth", needs_auth: "mcp.status.needs_auth",
needs_client_registration: "mcp.status.needs_client_registration",
disabled: "mcp.status.disabled", disabled: "mcp.status.disabled",
} as const } as const
@@ -56,7 +57,7 @@ export const DialogSelectMcp: Component = () => {
} }
const error = () => { const error = () => {
const s = mcpStatus() const s = mcpStatus()
if (s?.status === "failed") return s.error if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
} }
const enabled = () => status() === "connected" const enabled = () => status() === "connected"
return ( return (
@@ -5,7 +5,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { ReferenceInfo } from "@opencode-ai/client/promise" import type { ReferenceInfo } from "@/types"
import { createEffect, createMemo, on, Show } from "solid-js" import { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
+1 -1
View File
@@ -81,7 +81,7 @@ import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/place
import { createPromptInputTransientState } from "./prompt-input/transient-state" import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
import type { ReferenceInfo } from "@opencode-ai/client/promise" import type { ReferenceInfo } from "@/types"
export { createPromptInputHistory } export { createPromptInputHistory }
export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission } export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
@@ -1,15 +1,12 @@
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types" import type { AgentPartInput, FilePartInput, Part, TextPartInput } from "@/types"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path" import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note" import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
type PromptRequestPart = type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
| (Omit<TextPart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<FilePart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<MessageAgentPart, "id" | "sessionID" | "messageID"> & { id: string })
type ContextFile = { type ContextFile = {
key: string key: string
@@ -1,5 +1,4 @@
import type { Message } from "@/types" import type { Message, Session } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
@@ -22,6 +21,7 @@ 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: SessionInfo) => { const seed = (dir: string, info: Session) => {
serverSync().session.remember(info) serverSync().session.remember(info)
const [, setStore] = serverSync().child(dir) const [, setStore] = serverSync().child(dir)
setStore("session", (list: SessionInfo[]) => { setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id) const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list] const next = [...list]
if (result.found) { if (result.found) {
@@ -407,6 +407,7 @@ 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"),
@@ -33,7 +33,7 @@ export const DialogSettings: Component<{
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined return draft?.type === "draft" ? draft.directory : undefined
} }
if (route.type === "session") return serverSync().session.get(route.sessionId)?.location.directory if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
return undefined return undefined
}) })
@@ -96,7 +96,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.location.directory return serverSync().session.lineage.peek(props.sessionID)?.session.directory
}) })
const accepting = createMemo(() => { const accepting = createMemo(() => {
const value = dir() const value = dir()
@@ -426,7 +426,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
"bg-icon-success-base": status() === "connected", "bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed", "bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled", "bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base": status() === "needs_auth", "bg-icon-warning-base":
status() === "needs_auth" || status() === "needs_client_registration",
}} }}
/> />
<span class="flex flex-col min-w-0 flex-1"> <span class="flex flex-col min-w-0 flex-1">
@@ -35,6 +35,7 @@ describe("hasNonBlockingServiceIssue", () => {
test("detects MCP failures that do not block chatting", () => { test("detects MCP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false) expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
}) })
@@ -47,6 +48,7 @@ describe("hasNonBlockingServiceIssue", () => {
describe("hasServiceNeedingAttention", () => { describe("hasServiceNeedingAttention", () => {
test("detects MCP states that need user attention", () => { test("detects MCP states that need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true) expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
}) })
test("ignores states that do not need user attention", () => { test("ignores states that do not need user attention", () => {
@@ -2,7 +2,7 @@ import type { LspStatus } from "@/types"
import type { McpServer } from "@opencode-ai/client/promise" import type { McpServer } from "@opencode-ai/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) { export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth") return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
} }
export function hasNonBlockingServiceIssue(input: { export function hasNonBlockingServiceIssue(input: {
@@ -8,8 +8,7 @@ import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers" import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { Session } from "@/types"
import { sessionLabel } from "@/utils/session-title"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover" import { TabPreviewPopover } from "./titlebar-tab-popover"
import "./titlebar-tab-nav.css" import "./titlebar-tab-nav.css"
@@ -21,7 +20,7 @@ export function TabNavItem(props: {
ref?: Ref<HTMLDivElement> ref?: Ref<HTMLDivElement>
href: string href: string
server: ServerConnection.Key server: ServerConnection.Key
session: () => SessionInfo | undefined session: () => Session | undefined
fallbackTitle?: string fallbackTitle?: string
onRename: (title: string) => Promise<void> onRename: (title: string) => Promise<void>
onClose: () => void onClose: () => void
@@ -55,21 +54,18 @@ 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(() => { const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
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.location.directory }) return displayName(project() ?? { worktree: session.directory })
}) })
const previewPath = createMemo(() => { const previewPath = createMemo(() => {
const session = props.session() const session = props.session()
if (!session) return if (!session) return
const home = serverCtx()?.sync.data.path.home const home = serverCtx()?.sync.data.path.home
return home ? session.location.directory.replace(home, "~") : session.location.directory return home ? session.directory.replace(home, "~") : session.directory
}) })
// Only label the server when multiple servers are connected. // Only label the server when multiple servers are connected.
const serverLabel = createMemo(() => { const serverLabel = createMemo(() => {
@@ -235,7 +231,7 @@ export function TabNavItem(props: {
{(session) => ( {(session) => (
<SessionTabAvatar <SessionTabAvatar
project={project()} project={project()}
directory={session().location.directory} directory={session().directory}
sessionId={session().id} sessionId={session().id}
server={props.server} server={props.server}
/> />
@@ -19,7 +19,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order" import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { Session } from "@/types"
function SessionTabSlot(props: { function SessionTabSlot(props: {
tab: SessionTab tab: SessionTab
@@ -27,7 +27,7 @@ function SessionTabSlot(props: {
index: () => number index: () => number
active: () => boolean active: () => boolean
forceTruncate: boolean forceTruncate: boolean
session: () => SessionInfo | undefined session: () => Session | undefined
fallbackTitle?: string fallbackTitle?: string
onRename: (title: string) => Promise<void> onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void onNavigate: (element: HTMLDivElement) => void
@@ -127,7 +127,7 @@ function SessionTabEntry(props: {
createRoot((dispose) => { createRoot((dispose) => {
try { try {
void ctx.sync void ctx.sync
.ensureDirSyncContext(value.location.directory) .ensureDirSyncContext(value.directory)
.session.sync(value.id) .session.sync(value.id)
.catch(() => {}) .catch(() => {})
.finally(dispose) .finally(dispose)
@@ -144,7 +144,7 @@ function SessionTabEntry(props: {
const current = sdk() const current = sdk()
if (!current) return if (!current) return
createTabPromptState(tabs, props.tab, current.scope, { createTabPromptState(tabs, props.tab, current.scope, {
dir: base64Encode(value.location.directory), dir: base64Encode(value.directory),
id: value.id, id: value.id,
}) })
}) })
+3 -1
View File
@@ -27,6 +27,7 @@ import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt" import type { PromptSession } from "@/context/prompt"
import "./titlebar.css" import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind" import { newTabTooltipKeybind } from "./command-tooltip-keybind"
import { normalizeSessionInfo } from "@/utils/session"
const legacyTitlebarHeight = 40 const legacyTitlebarHeight = 40
const v2TitlebarHeight = 36 const v2TitlebarHeight = 36
@@ -193,6 +194,7 @@ 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(() => {}),
) )
@@ -254,7 +256,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
sessionId: activeSession.id, sessionId: activeSession.id,
} }
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current() const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model) tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
return return
} }
+5
View File
@@ -248,6 +248,11 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
return IS_MAC ? parts.join("") : parts.join("+") return IS_MAC ? parts.join("") : parts.join("+")
} }
// KeybindV2 takes an array instead of a string
export function formatKeybindKeys(config: string, t?: (key: KeyLabel) => string): string[] {
return formatKeybindParts(config, t)
}
function isEditableTarget(target: EventTarget | null) { function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true if (target.isContentEditable) return true
+6 -5
View File
@@ -1,11 +1,11 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import type { Message, Part } from "@/types" import type { Message, Part, Session } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { produce, reconcile, type SetStoreFunction } from "solid-js/store" import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk" import type { createServerSdkContext } from "./server-sdk"
import type { createServerSyncContextInner } from "./server-sync" import type { createServerSyncContextInner } from "./server-sync"
import type { State } from "./global-sync/types" import type { State } from "./global-sync/types"
import { normalizeSessionInfo } from "@/utils/session"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const sessionFields = new Set([ const sessionFields = new Set([
@@ -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.location.directory !== directory) return if (!session || session.directory !== directory) return
const [store, setStore] = current() const [store, setStore] = current()
const result = Binary.search(store.session, session.id, (item) => item.id) const result = Binary.search(store.session, session.id, (item) => item.id)
if (result.found) { if (result.found) {
@@ -74,13 +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: SessionInfo) { remember(session: Session) {
serverSync.session.remember(session) serverSync.session.remember(session)
index(session.id) index(session.id)
}, },
get(sessionID: string) { get(sessionID: string) {
const session = serverSync.session.get(sessionID) const session = serverSync.session.get(sessionID)
if (session?.location.directory === directory) return session if (session?.directory === directory) return session
}, },
optimistic: { optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) { add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
@@ -125,6 +125,7 @@ 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,6 +286,13 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
children: tree.children, children: tree.children,
expand: tree.expandDir, expand: tree.expandDir,
collapse: tree.collapseDir, collapse: tree.collapseDir,
toggle(input: string) {
if (tree.dirState(input)?.expanded) {
tree.collapseDir(input)
return
}
tree.expandDir(input)
},
}, },
get, get,
load, load,
@@ -1,8 +1,12 @@
import 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,
@@ -13,16 +17,12 @@ import type {
CommandListOutput, CommandListOutput,
LocationGetInput, LocationGetInput,
LocationGetOutput, LocationGetOutput,
PermissionRequest,
ProjectCurrentInput, ProjectCurrentInput,
ProjectCurrentOutput, ProjectCurrentOutput,
ProjectListOutput, ProjectListOutput,
ReferenceListInput, ReferenceListInput,
ReferenceListOutput, ReferenceListOutput,
ReferenceInfo,
QuestionRequest,
SessionApi, SessionApi,
SessionInfo,
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
@@ -34,6 +34,7 @@ import type { ServerSession } from "../server-session"
import { import {
cmp, cmp,
normalizeAgentList, normalizeAgentList,
normalizePermissionRequest,
normalizeProjectInfo, normalizeProjectInfo,
normalizeProviderList, normalizeProviderList,
} from "./utils" } from "./utils"
@@ -42,6 +43,7 @@ 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 = {
@@ -181,7 +183,7 @@ function projectID(directory: string, projects: Project[]) {
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
} }
function mergeSession(setStore: SetStoreFunction<State>, session: SessionInfo) { function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
setStore("session", (list) => { setStore("session", (list) => {
const next = list.slice() const next = list.slice()
const idx = next.findIndex((item) => item.id >= session.id) const idx = next.findIndex((item) => item.id >= session.id)
@@ -206,7 +208,9 @@ function warmSessions(input: {
if (ids.length === 0) return Promise.resolve() if (ids.length === 0) return Promise.resolve()
return Promise.all( return Promise.all(
ids.map((sessionID) => ids.map((sessionID) =>
retry(() => input.api.get({ sessionID })).then((session) => mergeSession(input.setStore, session)), retry(() => input.api.get({ sessionID })).then((session) =>
mergeSession(input.setStore, normalizeSessionInfo(session)),
),
), ),
).then(() => undefined) ).then(() => undefined)
} }
@@ -364,7 +368,7 @@ export async function bootstrapDirectory(input: {
retry(() => retry(() =>
input.api.permission.request input.api.permission.request
.list({ location: { directory: input.directory } }) .list({ location: { directory: input.directory } })
.then((result) => result.data) .then((result) => result.data.map(normalizePermissionRequest))
.then((permissions) => { .then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID) const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession( const grouped = groupBySession(
@@ -378,7 +382,7 @@ export async function bootstrapDirectory(input: {
const current = input.session?.data.permission ?? input.store.permission const current = input.session?.data.permission ?? input.store.permission
for (const sessionID of Object.keys(current)) { for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue if (input.session?.get(sessionID)?.directory !== input.directory) continue
if (input.session) input.session.set("permission", sessionID, []) if (input.session) input.session.set("permission", sessionID, [])
if (!input.session) input.setStore("permission", sessionID, []) if (!input.session) input.setStore("permission", sessionID, [])
} }
@@ -412,7 +416,7 @@ export async function bootstrapDirectory(input: {
const current = input.session?.data.question ?? input.store.question const current = input.session?.data.question ?? input.store.question
for (const sessionID of Object.keys(current)) { for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue if (input.session?.get(sessionID)?.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, []) if (input.session) input.session.set("question", sessionID, [])
if (!input.session) input.setStore("question", sessionID, []) if (!input.session) input.setStore("question", sessionID, [])
} }
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message, Part, Project } from "@/types" import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@/types"
import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { State } from "./types" import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
@@ -14,7 +13,7 @@ const rootSession = (input: { id: string; parentID?: string; archived?: number }
updated: 1, updated: 1,
archived: input.archived, archived: input.archived,
}, },
}) as SessionInfo }) as Session
const userMessage = (id: string, sessionID: string) => const userMessage = (id: string, sessionID: string) =>
({ ({
@@ -39,10 +38,10 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
({ ({
id, id,
sessionID, sessionID,
action: title, permission: title,
resources: ["*"], patterns: ["*"],
metadata: {}, metadata: {},
save: [], always: [],
}) as PermissionRequest }) as PermissionRequest
const questionRequest = (id: string, sessionID: string, title = id) => const questionRequest = (id: string, sessionID: string, title = id) =>
@@ -513,7 +512,7 @@ describe("applyDirectoryEvent", () => {
directory: "/tmp", directory: "/tmp",
loadLsp() {}, loadLsp() {},
}) })
expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.action).toBe("updated") expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.permission).toBe("updated")
applyDirectoryEvent({ applyDirectoryEvent({
event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } }, event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } },
@@ -3,10 +3,14 @@ import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/
import type { import type {
Message, Message,
Part, Part,
PermissionRequest,
Project, Project,
QuestionRequest,
Session,
SessionStatus,
Todo, Todo,
} from "@/types" } from "@/types"
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache" import { dropSessionCaches } from "./session-cache"
@@ -75,7 +79,7 @@ function cleanupSessionCaches(
export function cleanupDroppedSessionCaches( export function cleanupDroppedSessionCaches(
store: Store<State>, store: Store<State>,
setStore: SetStoreFunction<State>, setStore: SetStoreFunction<State>,
next: SessionInfo[], next: Session[],
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) { ) {
const keep = new Set(next.map((item) => item.id)) const keep = new Set(next.map((item) => item.id))
@@ -124,7 +128,7 @@ export function applyDirectoryEvent(input: {
return return
} }
case "session.created": { case "session.created": {
const info = (event.properties as { info: SessionInfo }).info const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id) const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (result.found) { if (result.found) {
input.setStore("session", result.index, reconcile(info)) input.setStore("session", result.index, reconcile(info))
@@ -139,7 +143,7 @@ export function applyDirectoryEvent(input: {
break break
} }
case "session.updated": { case "session.updated": {
const info = (event.properties as { info: SessionInfo }).info const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id) const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (info.time.archived) { if (info.time.archived) {
if (!result.found) break if (!result.found) break
@@ -167,7 +171,7 @@ export function applyDirectoryEvent(input: {
break break
} }
case "session.deleted": { case "session.deleted": {
const properties = event.properties as { sessionID?: string; info?: SessionInfo } const properties = event.properties as { sessionID?: string; info?: Session }
const sessionID = properties.info?.id ?? properties.sessionID const sessionID = properties.info?.id ?? properties.sessionID
if (!sessionID) break if (!sessionID) break
const result = Binary.search(input.store.session, sessionID, (s) => s.id) const result = Binary.search(input.store.session, sessionID, (s) => s.id)
@@ -197,7 +201,7 @@ export function applyDirectoryEvent(input: {
break break
} }
case "session.usage.updated": { case "session.usage.updated": {
const properties = event.properties as Pick<SessionInfo, "cost" | "tokens"> & { sessionID: string } const properties = event.properties as Pick<Session, "cost" | "tokens"> & { sessionID: string }
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
if (!result.found) break if (!result.found) break
input.setStore("session", result.index, (session) => ({ input.setStore("session", result.index, (session) => ({
@@ -233,8 +237,9 @@ export function applyDirectoryEvent(input: {
input.setStore("session", result.index, (session) => ({ input.setStore("session", result.index, (session) => ({
...session, ...session,
projectID: properties.projectID ?? session.projectID, projectID: properties.projectID ?? session.projectID,
location: properties.location, workspaceID: properties.location.workspaceID,
subpath: properties.subpath, directory: properties.location.directory,
path: properties.subpath,
time: { ...session.time, updated: Date.now() }, time: { ...session.time, updated: Date.now() },
})) }))
break break
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { SessionV2Info } from "@/types"
import { import {
applyHomeSessionEvent, applyHomeSessionEvent,
appendHomeSessionEvent, appendHomeSessionEvent,
@@ -48,7 +48,9 @@ describe("Home V2 session index", () => {
calls.push({ input, signal: options.signal }) calls.push({ input, signal: options.signal })
if (!("cursor" in input)) { if (!("cursor" in input)) {
return { return {
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session({ id: `page-1-${index}` })), data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
session({ id: `page-1-${index}` }),
),
cursor: { next: "next-page" }, cursor: { next: "next-page" },
} }
} }
@@ -72,7 +74,7 @@ describe("Home V2 session index", () => {
const activeNull = { const activeNull = {
...session({ id: "active-null", updated: 20 }), ...session({ id: "active-null", updated: 20 }),
time: { created: 1, updated: 20, archived: null }, time: { created: 1, updated: 20, archived: null },
} as unknown as SessionInfo } as unknown as SessionV2Info
const result = parseHomeSessionIndex([ const result = parseHomeSessionIndex([
session({ id: "root", updated: 30 }), session({ id: "root", updated: 30 }),
activeNull, activeNull,
@@ -81,9 +83,11 @@ describe("Home V2 session index", () => {
]) ])
expect(result).toEqual([ expect(result).toEqual([
expect.objectContaining({ expect.objectContaining({
id: "root", id: "root",
location: { directory: "/project" }, slug: "root",
version: "",
directory: "/project",
projectID: "project", projectID: "project",
title: "root", title: "root",
time: { created: 1, updated: 30 }, time: { created: 1, updated: 30 },
@@ -99,17 +103,17 @@ describe("Home V2 session index", () => {
const now = 10 * 60 * 60 * 1000 const now = 10 * 60 * 60 * 1000
const sessions = Array.from({ length: 80 }, (_, index) => ({ const sessions = Array.from({ length: 80 }, (_, index) => ({
...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0], ...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
location: { directory: index % 2 === 0 ? "/one" : "/two" }, directory: index % 2 === 0 ? "/one" : "/two",
})) }))
const retained = retainHomeSessions(sessions, 10, now) const retained = retainHomeSessions(sessions, 10, now)
expect(retained.filter((item) => item.location.directory === "/one")).toHaveLength(10) expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
expect(retained.filter((item) => item.location.directory === "/two")).toHaveLength(10) expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
}) })
test("replays session events over the loaded index", () => { test("replays session events over the loaded index", () => {
const initial = parseHomeSessionIndex([session({ id: "old" })]) const initial = parseHomeSessionIndex([session({ id: "old" })])
const created = { ...initial[0], id: "new", title: "new", time: { created: 2, updated: 2 } } const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
const afterCreate = applyHomeSessionEvent(initial, { const afterCreate = applyHomeSessionEvent(initial, {
type: "session.created", type: "session.created",
@@ -1,5 +1,5 @@
import type { Event } from "@/types" import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@/types"
import type { SessionInfo, SessionsResponse } from "@opencode-ai/client/promise" import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
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?: SessionInfo } properties: { sessionID: string; info: Session }
} }
export type HomeSessionEvents = { export type HomeSessionEvents = {
sequence: number sequence: number
entries: Array<{ sequence: number; event: HomeSessionEvent }> entries: Array<{ sequence: number; event: HomeSessionEvent }>
} }
export type HomeSessionIndex = { export type HomeSessionIndex = {
sessions: SessionInfo[] sessions: Session[]
eventSequence: number eventSequence: number
} }
@@ -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<SessionsResponse>, ) => Promise<V2SessionListResponse>,
eventSequence = 0, eventSequence = 0,
signal?: AbortSignal, signal?: AbortSignal,
) { ) {
const data: SessionInfo[] = [] const data: SessionV2Info[] = []
let cursor: string | undefined let cursor: string | undefined
for (;;) { for (;;) {
@@ -125,28 +125,23 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st
// current V2 API orders by creation time and cannot filter roots, archives, or // current V2 API orders by creation time and cannot filter roots, archives, or
// multiple directories. A bounded page could omit an old session updated today. // multiple directories. A bounded page could omit an old session updated today.
// Once released, use client.v2.project.list() and client.v2.session.list({ // Once released, use client.v2.project.list() and client.v2.session.list({
// parentID: null, order: "desc" }) and replace this full-table scan. // parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
export function parseHomeSessionIndex(sessions: SessionInfo[]): SessionInfo[] { export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
return sessions.flatMap((item) => { return sessions.flatMap((item) => {
if (item.parentID || typeof item.time.archived === "number") return [] if (item.parentID || typeof item.time.archived === "number") return []
return [item] return [toLegacySummary(item)]
}) })
} }
export function retainHomeSessions(sessions: SessionInfo[], limit: number, now: number) { export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
const grouped = Map.groupBy(sessions, (session) => pathKey(session.location.directory)) const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now })) return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
} }
export function applyHomeSessionEvent(sessions: SessionInfo[], event: HomeSessionEvent) { export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
const info = event.properties.info const info = event.properties.info
const index = sessions.findIndex((session) => session.id === (info?.id ?? event.properties.sessionID)) const index = sessions.findIndex((session) => session.id === info.id)
if (event.type === "session.deleted") { if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
if (index === -1) return sessions
return sessions.toSpliced(index, 1)
}
if (!info) return sessions
if (info.parentID || typeof info.time.archived === "number") {
if (index === -1) return sessions if (index === -1) return sessions
return sessions.toSpliced(index, 1) return sessions.toSpliced(index, 1)
} }
@@ -154,3 +149,22 @@ export function applyHomeSessionEvent(sessions: SessionInfo[], event: HomeSessio
if (index === -1) return [...sessions, info] if (index === -1) return [...sessions, info]
return sessions.with(index, info) return sessions.with(index, info)
} }
function toLegacySummary(session: SessionV2Info): Session {
return {
id: session.id,
slug: session.id,
projectID: session.projectID,
workspaceID: session.location.workspaceID,
directory: session.location.directory,
path: session.subpath,
parentID: session.parentID,
cost: session.cost,
tokens: session.tokens,
title: withTimestampedFallback(session),
agent: session.agent,
model: session.model,
version: "",
time: session.time,
}
}
@@ -13,6 +13,7 @@ export async function toggleMcp(input: {
needs_auth: input.authenticate, needs_auth: input.authenticate,
disabled: input.connect, disabled: input.connect,
failed: input.connect, failed: input.connect,
needs_client_registration: input.connect,
}[input.status]() }[input.status]()
await input.refresh() await input.refresh()
} }
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message, Part, Todo } from "@/types" import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types"
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -1,5 +1,4 @@
import type { Message, Part, Todo } from "@/types" import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types"
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionMessageInfo } from "@opencode-ai/client/promise" import type { SessionMessageInfo } from "@opencode-ai/client/promise"
@@ -1,4 +1,5 @@
import type { SessionApi } from "@opencode-ai/client/promise" import type { SessionApi } from "@opencode-ai/client/promise"
import { normalizeSessionInfo } from "@/utils/session"
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) { export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
const result = await input.api.list({ const result = await input.api.list({
@@ -8,7 +9,7 @@ export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; d
order: "desc", order: "desc",
}) })
return { return {
data: result.data, data: result.data.map(normalizeSessionInfo),
limit: input.limit, limit: input.limit,
limited: true, limited: true,
} as const } as const
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise" import type { PermissionRequest, Session } from "@/types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) => const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
@@ -11,7 +11,7 @@ const session = (input: { id: string; parentID?: string; created: number; update
updated: input.updated, updated: input.updated,
archived: input.archived, archived: input.archived,
}, },
}) as SessionInfo }) as Session
describe("trimSessions", () => { describe("trimSessions", () => {
test("keeps base roots and recent roots beyond the limit", () => { test("keeps base roots and recent roots beyond the limit", () => {
@@ -1,22 +1,21 @@
import type { SessionInfo } from "@opencode-ai/client/promise" import type { PermissionRequest, Session } from "@/types"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import { cmp } from "./utils" import { cmp } from "./utils"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types" import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
export function sessionUpdatedAt(session: SessionInfo) { export function sessionUpdatedAt(session: Session) {
return session.time.updated ?? session.time.created return session.time.updated ?? session.time.created
} }
export function compareSessionRecent(a: SessionInfo, b: SessionInfo) { export function compareSessionRecent(a: Session, b: Session) {
const aUpdated = sessionUpdatedAt(a) const aUpdated = sessionUpdatedAt(a)
const bUpdated = sessionUpdatedAt(b) const bUpdated = sessionUpdatedAt(b)
if (aUpdated !== bUpdated) return bUpdated - aUpdated if (aUpdated !== bUpdated) return bUpdated - aUpdated
return cmp(a.id, b.id) return cmp(a.id, b.id)
} }
export function takeRecentSessions(sessions: SessionInfo[], limit: number, cutoff: number) { export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
if (limit <= 0) return [] as SessionInfo[] if (limit <= 0) return [] as Session[]
const selected: SessionInfo[] = [] const selected: Session[] = []
const seen = new Set<string>() const seen = new Set<string>()
for (const session of sessions) { for (const session of sessions) {
if (!session?.id) continue if (!session?.id) continue
@@ -32,7 +31,7 @@ export function takeRecentSessions(sessions: SessionInfo[], limit: number, cutof
} }
export function trimSessions( export function trimSessions(
input: SessionInfo[], input: Session[],
options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number }, options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
) { ) {
const limit = Math.max(0, options.limit) const limit = Math.max(0, options.limit)
@@ -5,17 +5,15 @@ import type {
Message, Message,
Part, Part,
Path, Path,
Todo,
VcsInfo,
} from "@/types"
import type {
FileDiffInfo,
PermissionRequest, PermissionRequest,
QuestionRequest, QuestionRequest,
ReferenceInfo, ReferenceInfo,
SessionInfo, Session,
SessionStatus, SessionStatus,
} from "@opencode-ai/client/promise" Todo,
VcsInfo,
} from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise" import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
@@ -44,7 +42,7 @@ export type State = {
provider: NormalizedProviderListResponse provider: NormalizedProviderListResponse
config: Config config: Config
path: Path path: Path
session: SessionInfo[] session: Session[]
sessionTotal: number sessionTotal: number
session_status: { session_status: {
[sessionID: string]: SessionStatus [sessionID: string]: SessionStatus
@@ -5,7 +5,7 @@ import type {
ModelListOutput, ModelListOutput,
ProviderListOutput, ProviderListOutput,
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils" import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
describe("normalizeAgentList", () => { describe("normalizeAgentList", () => {
test("adapts current agents to the app agent shape", () => { test("adapts current agents to the app agent shape", () => {
@@ -43,6 +43,30 @@ describe("normalizeAgentList", () => {
}) })
}) })
describe("normalizePermissionRequest", () => {
test("adapts the current permission request to app state", () => {
expect(
normalizePermissionRequest({
id: "permission-1",
sessionID: "session-1",
action: "read",
resources: ["README.md"],
save: ["*.md"],
metadata: { path: "README.md" },
source: { type: "tool", messageID: "message-1", id: "call-1" },
}),
).toEqual({
id: "permission-1",
sessionID: "session-1",
permission: "read",
patterns: ["README.md"],
always: ["*.md"],
metadata: { path: "README.md" },
tool: { messageID: "message-1", callID: "call-1" },
})
})
})
describe("normalizeProviderList", () => { describe("normalizeProviderList", () => {
test("groups current models into the app provider catalog", () => { test("groups current models into the app provider catalog", () => {
const result = normalizeProviderList( const result = normalizeProviderList(
+30 -1
View File
@@ -2,9 +2,10 @@ import type {
AgentListOutput, AgentListOutput,
ModelDefaultOutput, ModelDefaultOutput,
ModelListOutput, ModelListOutput,
PermissionRequest,
ProviderListOutput, ProviderListOutput,
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import type { Agent, Project, Provider, ProviderListResponse } from "@/types" import type { Agent, Event, Project, Provider, ProviderListResponse } from "@/types"
import type { Project as CurrentProject } from "@opencode-ai/client/promise" import type { Project as CurrentProject } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
@@ -35,6 +36,22 @@ 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"],
@@ -123,6 +140,18 @@ export function normalizeProviderList(
} }
} }
export function sanitizeProject(project: Project) {
if (!project.icon?.url && !project.icon?.override) return project
return {
...project,
icon: {
...project.icon,
url: undefined,
override: undefined,
},
}
}
export function normalizeProjectInfo(project: Project | CurrentProject): Project { export function normalizeProjectInfo(project: Project | CurrentProject): Project {
return { return {
...project, ...project,
+30
View File
@@ -744,6 +744,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
mobileSidebar: { mobileSidebar: {
opened: createMemo(() => store.mobileSidebar?.opened ?? false), opened: createMemo(() => store.mobileSidebar?.opened ?? false),
show() {
setStore("mobileSidebar", "opened", true)
},
hide() { hide() {
setStore("mobileSidebar", "opened", false) setStore("mobileSidebar", "opened", false)
}, },
@@ -949,6 +952,33 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (current.reviewOpen.includes(path)) return if (current.reviewOpen.includes(path)) return
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path) setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
}, },
closePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current) return
const index = current.indexOf(path)
if (index === -1) return
setStore(
"sessionView",
session,
"reviewOpen",
produce((draft) => {
if (!draft) return
draft.splice(index, 1)
}),
)
},
togglePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current || !current.includes(path)) {
this.openPath(path)
return
}
this.closePath(path)
},
}, },
} }
}, },
+3 -3
View File
@@ -360,7 +360,7 @@ function createServerNotificationState(input: {
const handleSessionError = ( const handleSessionError = (
directory: string, directory: string,
event: { properties: EventSessionError["properties"] }, event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
time: number, time: number,
) => { ) => {
const sessionID = event.properties.sessionID const sessionID = event.properties.sessionID
@@ -372,7 +372,7 @@ function createServerNotificationState(input: {
void playSoundById(settings.sounds.errors()) void playSoundById(settings.sounds.errors())
} }
const error = event.properties.error const error = "error" in event.properties ? event.properties.error : undefined
append({ append({
directory, directory,
time, time,
@@ -393,7 +393,7 @@ function createServerNotificationState(input: {
const unsub = serverSDK().event.listen((e) => { const unsub = serverSDK().event.listen((e) => {
const event = e.details const event = e.details
if (event.type !== "session.idle" && event.type !== "session.execution.failed") return if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name const directory = e.name
const time = Date.now() const time = Date.now()
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise" import type { PermissionRequest, Session } from "@/types"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond" import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
@@ -7,7 +7,7 @@ const session = (input: { id: string; parentID?: string }) =>
({ ({
id: input.id, id: input.id,
parentID: input.parentID, parentID: input.parentID,
}) as SessionInfo }) as Session
const permission = (sessionID: string) => const permission = (sessionID: string) =>
({ ({
+6 -3
View File
@@ -1,7 +1,7 @@
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/client/promise" import type { PermissionRequest } from "@/types"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { ServerSDK } from "@/context/server-sdk" import type { ServerSDK } from "@/context/server-sdk"
import type { ServerSync } from "./server-sync" import type { ServerSync } from "./server-sync"
@@ -13,6 +13,7 @@ import { type DraftTab, useTabs } from "./tabs"
import { useSettings } from "./settings" import { useSettings } from "./settings"
import { requireServerKey } from "@/utils/session-route" import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { normalizePermissionRequest } from "./global-sync/utils"
import { import {
acceptKey, acceptKey,
directoryAcceptKey, directoryAcceptKey,
@@ -132,7 +133,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
if (draft) return draft.directory if (draft) return draft.directory
if (!params.id) return if (!params.id) return
if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return
return selected().sync.session.lineage.peek(params.id)?.session.location.directory return selected().sync.session.lineage.peek(params.id)?.session.directory
}) })
createEffect(() => { createEffect(() => {
@@ -255,7 +256,9 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
} }
const list = async (directory: string) => { const list = async (directory: string) => {
return input.sdk.api.permission.request.list({ location: { directory } }).then((result) => result.data) return input.sdk.api.permission.request
.list({ location: { directory } })
.then((result) => result.data.map(normalizePermissionRequest))
} }
function respondOnce(permission: PermissionRequest, directory?: string) { function respondOnce(permission: PermissionRequest, directory?: string) {
+9 -5
View File
@@ -15,7 +15,7 @@ describe("resumeStreamAfterPageShow", () => {
}) })
describe("adaptServerEvent", () => { describe("adaptServerEvent", () => {
test("preserves current permission requests", () => { test("preserves current events while adapting permission requests for existing consumers", () => {
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",
action: "read", permission: "read",
resources: ["src/**"], patterns: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" }, tool: { messageID: "msg_1", callID: "call_1" },
}, },
current, current,
}) })
@@ -87,7 +87,11 @@ 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(["evt_1", "evt_2", "evt_3"]) expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual([
"evt_1",
"evt_2",
"evt_3",
])
}) })
test("preserves current event order when enqueuing", () => { test("preserves current event order when enqueuing", () => {
+20 -1
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 } from "@/types" import type { Event, PermissionRequest } from "@/types"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
@@ -24,6 +24,25 @@ 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 } from "@/types" import type { Message, Part, Session } from "@/types"
import { createServerSession } from "./server-session" import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
type MessageApi = ServerApi["message"] type MessageApi = ServerApi["message"]
const session = (id: string, parentID?: string): SessionInfo => ({ const session = (id: string, parentID?: string): Session => ({
id, id,
slug: id,
projectID: "project", projectID: "project",
location: { directory: "/repo" }, directory: "/repo",
title: id, title: id,
cost: 0, version: "1",
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
parentID, parentID,
time: { created: 1, updated: 1 }, time: { created: 1, updated: 1 },
}) })
@@ -35,8 +35,18 @@ type MessageResponse = {
} }
type SingleMessageResponse = { data: MessageResponse["data"][number] } type SingleMessageResponse = { data: MessageResponse["data"][number] }
function sessionInfo(value: SessionInfo): SessionInfo { function sessionInfo(value: Session): SessionInfo {
return value return {
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[] {
@@ -262,7 +272,7 @@ const retryImmediately: typeof retry = async (task, options = {}) => {
} }
} }
function setup(sessions: Record<string, SessionInfo>) { function setup(sessions: Record<string, Session>) {
const get: unknown[] = [] const get: unknown[] = []
const messages: unknown[] = [] const messages: unknown[] = []
const client = { const client = {
@@ -1677,7 +1687,7 @@ describe("server session", () => {
ctx.store.apply({ type: "session.created", properties: { sessionID: "root", info: session("root") } }) ctx.store.apply({ type: "session.created", properties: { sessionID: "root", info: session("root") } })
ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } }) ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } })
expect(ctx.store.get("root")?.location.directory).toBe("/repo") expect(ctx.store.get("root")?.directory).toBe("/repo")
expect(ctx.store.data.session_working("root")).toBe(true) expect(ctx.store.data.session_working("root")).toBe(true)
expect(ctx.get).toEqual([]) expect(ctx.get).toEqual([])
}) })
+17 -12
View File
@@ -1,15 +1,20 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/core/util/retry"
import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise" import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { import type {
Message, Message,
Part, Part,
PermissionRequest,
QuestionRequest,
Session,
SessionStatus,
Todo, Todo,
} from "@/types" } from "@/types"
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { batch } from "solid-js" import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { 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"
@@ -187,7 +192,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, SessionInfo | undefined>, info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>, session_status: {} as Record<string, SessionStatus>,
session_diff: {} as Record<string, FileDiffInfo[]>, session_diff: {} as Record<string, FileDiffInfo[]>,
todo: {} as Record<string, Todo[]>, todo: {} as Record<string, Todo[]>,
@@ -201,7 +206,7 @@ export function createServerSession(
return (this.session_status[id]?.type ?? "idle") !== "idle" return (this.session_status[id]?.type ?? "idle") !== "idle"
}, },
}) })
const requests = new Map<string, Promise<SessionInfo>>() const requests = new Map<string, Promise<Session>>()
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>() const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>() const optimistic = new Map<string, Map<string, OptimisticItem>>()
@@ -250,7 +255,7 @@ export function createServerSession(
) )
} }
const remember = (session: SessionInfo) => { const remember = (session: Session) => {
setData("info", session.id, reconcile(session)) setData("info", session.id, reconcile(session))
infoSeen.delete(session.id) infoSeen.delete(session.id)
infoSeen.add(session.id) infoSeen.add(session.id)
@@ -300,7 +305,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 }) const request = sessionApi.get({ sessionID }).then(normalizeSessionInfo)
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)
@@ -916,8 +921,9 @@ export function createServerSession(
remember({ remember({
...info, ...info,
projectID: event.data.projectID ?? info.projectID, projectID: event.data.projectID ?? info.projectID,
location: event.data.location, workspaceID: event.data.location.workspaceID,
subpath: event.data.subpath, directory: event.data.location.directory,
path: event.data.subpath,
time: { ...info.time, updated: event.created }, time: { ...info.time, updated: event.created },
}) })
if (event.type === "session.usage.updated" && info) if (event.type === "session.usage.updated" && info)
@@ -963,17 +969,16 @@ export function createServerSession(
} }
switch (event.type) { switch (event.type) {
case "session.created": case "session.created":
if ((event.properties as { info?: SessionInfo }).info) remember((event.properties as { info: Session }).info)
remember((event.properties as { info: SessionInfo }).info)
return return
case "session.updated": { case "session.updated": {
const info = (event.properties as { info: SessionInfo }).info const info = (event.properties as { info: Session }).info
remember(info) remember(info)
if (info.time.archived) evict([info.id]) if (info.time.archived) evict([info.id])
return return
} }
case "session.deleted": { case "session.deleted": {
const properties = event.properties as { sessionID?: string; info?: SessionInfo } const properties = event.properties as { sessionID?: string; info?: Session }
const sessionID = properties.info?.id ?? properties.sessionID const sessionID = properties.info?.id ?? properties.sessionID
if (!sessionID) return if (!sessionID) return
infoSeen.delete(sessionID) infoSeen.delete(sessionID)
+4 -2
View File
@@ -123,7 +123,7 @@ describe("pickDirectoriesToEvict", () => {
}) })
describe("loadRootSessions", () => { describe("loadRootSessions", () => {
test("loads a limited page of root sessions", async () => { test("loads and normalizes a limited page of root sessions", async () => {
const calls: SessionListInput[] = [] const calls: SessionListInput[] = []
const result = await loadRootSessions({ const result = await loadRootSessions({
@@ -137,7 +137,9 @@ describe("loadRootSessions", () => {
limit: 10, limit: 10,
}) })
expect(result.data).toEqual([expect.objectContaining({ id: "session-1", location: { directory: "dir" } })]) expect(result.data).toEqual([
expect.objectContaining({ id: "session-1", directory: "dir", slug: "session-1", version: "" }),
])
expect(result.limited).toBe(true) expect(result.limited).toBe(true)
expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }]) expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }])
}) })
+3 -20
View File
@@ -3,6 +3,7 @@ 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"
@@ -54,7 +55,6 @@ 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.location.directory) const key = directoryKey(info.directory)
const existing = children.children[key] const existing = children.children[key]
if (!existing) return if (!existing) return
applyDirectoryEvent({ applyDirectoryEvent({
@@ -476,24 +476,7 @@ 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.current?.type === "session.created") if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
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)
+4 -5
View File
@@ -1,4 +1,4 @@
import type { SessionInfo } from "@opencode-ai/client/promise" import type { Session } from "@/types"
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: SessionInfo) { export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
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,11 +349,10 @@ 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: SessionInfo) { rememberSessionInfo(tab: SessionTab, session: Session) {
const key = tabKey(tab) const key = tabKey(tab)
const next = { title: session.title, directory: session.location.directory } const next = { title: session.title, directory: session.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", location: { directory: "/workspace" } }, session: { id: "ses_1", 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", location: { directory: "/workspace" } }, session: { id: "ses_1", directory: "/workspace" },
archive: async () => Promise.reject(failure), archive: async () => Promise.reject(failure),
remove: () => { remove: () => {
removed = true removed = true
@@ -1,8 +1,10 @@
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 = Pick<SessionInfo, "id" | "location"> type HomeSession = {
id: string
directory: string
}
export async function archiveHomeSession(input: { export async function archiveHomeSession(input: {
server: ServerConnection.Key server: ServerConnection.Key
@@ -17,7 +19,7 @@ export async function archiveHomeSession(input: {
input.remove() input.remove()
notifySessionTabsRemoved({ notifySessionTabsRemoved({
server: input.server, server: input.server,
directory: input.session.location.directory, directory: input.session.directory,
sessionIDs: [input.session.id], sessionIDs: [input.session.id],
}) })
}) })
@@ -2,7 +2,6 @@ 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"
@@ -24,7 +23,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
if (!value) return [] if (!value) return []
return sessions.data return sessions.data
.searchRecords() .searchRecords()
.filter((record) => `${sessionLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value)) .filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
}) })
const active = createMemo(() => { const active = createMemo(() => {
const records = results() const records = results()
@@ -1,4 +1,4 @@
import type { SessionInfo } from "@opencode-ai/client/promise" import type { Session } from "@/types"
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: SessionInfo session: Session
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: SessionInfo, options?: OpenSessionOptions) => { open: (session: Session, options?: OpenSessionOptions) => {
const directoryKey = pathKey(session.location.directory) const directoryKey = pathKey(session.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.location.directory const directory = project?.worktree ?? session.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: SessionInfo) => { archive: async (session: Session) => {
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.location.directory) const [, setStore] = ctx.sync.child(session.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: () => SessionInfo[] sessions: () => Session[]
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.location.directory))) const sessions = input.sessions().filter((session) => directories.has(pathKey(session.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.location.directory) const directory = pathKey(session.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.location.directory)}:${record.session.id}` return `${pathKey(record.session.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.location.directory, () => props.record.session.directory,
() => props.record.session.id, () => props.record.session.id,
) )
return props.render({ return props.render({
@@ -1,4 +1,4 @@
import type { SessionInfo } from "@opencode-ai/client/promise" import type { Session } from "@/types"
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 { sessionLabel } from "@/utils/session-title" import { sessionTitle } 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: SessionInfo, options?: OpenSessionOptions) => void onOpenSession: (session: Session, options?: OpenSessionOptions) => void
onArchiveSession: (session: SessionInfo) => Promise<void> onArchiveSession: (session: Session) => 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.location.directory} directory={props.record.session.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(() => sessionLabel(props.record.session)) const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
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(() => sessionLabel(props.record.session)) const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName() && props.record.projectName const showProjectName = () => props.showProjectName() && props.record.projectName
return ( return (
+17 -17
View File
@@ -26,7 +26,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { Session } from "@/types"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
@@ -603,9 +603,9 @@ export default function LegacyLayout(props: ParentProps) {
const currentSessions = createMemo(() => { const currentSessions = createMemo(() => {
const now = Date.now() const now = Date.now()
const dirs = visibleSessionDirs() const dirs = visibleSessionDirs()
if (dirs.length === 0) return [] as SessionInfo[] if (dirs.length === 0) return [] as Session[]
const result: SessionInfo[] = [] const result: Session[] = []
for (const dir of dirs) { for (const dir of dirs) {
const [dirStore] = serverSync().child(dir, { bootstrap: true }) const [dirStore] = serverSync().child(dir, { bootstrap: true })
const dirSessions = sortedRootSessions(dirStore, now) const dirSessions = sortedRootSessions(dirStore, now)
@@ -717,8 +717,8 @@ export default function LegacyLayout(props: ParentProps) {
}) })
} }
const prefetchSession = (session: SessionInfo, priority: "high" | "low" = "low") => { const prefetchSession = (session: Session, priority: "high" | "low" = "low") => {
const directory = session.location.directory const directory = session.directory
if (!directory) return if (!directory) return
const cached = untrack(() => !serverSync().session.shouldPrefetch(session.id, prefetchChunk)) const cached = untrack(() => !serverSync().session.shouldPrefetch(session.id, prefetchChunk))
@@ -753,7 +753,7 @@ export default function LegacyLayout(props: ParentProps) {
pumpPrefetch(directory) pumpPrefetch(directory)
} }
const warm = (sessions: SessionInfo[], index: number) => { const warm = (sessions: Session[], index: number) => {
for (let offset = 1; offset <= span; offset++) { for (let offset = 1; offset <= span; offset++) {
const next = sessions[index + offset] const next = sessions[index + offset]
if (next) prefetchSession(next, offset === 1 ? "high" : "low") if (next) prefetchSession(next, offset === 1 ? "high" : "low")
@@ -855,11 +855,11 @@ export default function LegacyLayout(props: ParentProps) {
} }
} }
async function archiveSession(session: SessionInfo) { async function archiveSession(session: Session) {
// TODO: Restore archiving when the V2 client exposes a session archive API. // TODO: Restore archiving when the V2 client exposes a session archive API.
void session void session
return return
const [store, setStore] = serverSync().child(session.location.directory) const [store, setStore] = serverSync().child(session.directory)
const sessions = store.session ?? [] const sessions = store.session ?? []
const index = sessions.findIndex((s) => s.id === session.id) const index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1] const nextSession = sessions[index + 1] ?? sessions[index - 1]
@@ -1192,10 +1192,10 @@ export default function LegacyLayout(props: ParentProps) {
.sync(target.id) .sync(target.id)
.then(() => sync.session.get(target.id)) .then(() => sync.session.get(target.id))
.catch(() => undefined) .catch(() => undefined)
if (!resolved?.location.directory) return false if (!resolved?.directory) return false
if (!canOpen(resolved.location.directory)) return false if (!canOpen(resolved.directory)) return false
setStore("lastProjectSession", root, { directory: resolved.location.directory, id: resolved.id, at: Date.now() }) setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(resolved.location.directory)}/session/${resolved.id}`) navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
return true return true
} }
@@ -1211,7 +1211,7 @@ export default function LegacyLayout(props: ParentProps) {
dirs.map((item) => serverSync().child(item, { bootstrap: false })[0]), dirs.map((item) => serverSync().child(item, { bootstrap: false })[0]),
Date.now(), Date.now(),
) )
if (latest && (await openSession({ directory: latest.location.directory, id: latest.id }))) { if (latest && (await openSession(latest))) {
return return
} }
@@ -1228,16 +1228,16 @@ export default function LegacyLayout(props: ParentProps) {
), ),
Date.now(), Date.now(),
) )
if (fetched && (await openSession({ directory: fetched.location.directory, id: fetched.id }))) { if (fetched && (await openSession(fetched))) {
return return
} }
navigateWithSidebarReset(`/${base64Encode(root)}/session`) navigateWithSidebarReset(`/${base64Encode(root)}/session`)
} }
function navigateToSession(session: SessionInfo | undefined) { function navigateToSession(session: Session | undefined) {
if (!session) return if (!session) return
navigateWithSidebarReset(`/${base64Encode(session.location.directory)}/session/${session.id}`) navigateWithSidebarReset(`/${base64Encode(session.directory)}/session/${session.id}`)
} }
function openProject(directory: string, navigate = true) { function openProject(directory: string, navigate = true) {
@@ -1540,7 +1540,7 @@ export default function LegacyLayout(props: ParentProps) {
const [state, setState] = createStore({ const [state, setState] = createStore({
status: "loading" as "loading" | "ready" | "error", status: "loading" as "loading" | "ready" | "error",
dirty: false, dirty: false,
sessions: [] as SessionInfo[], sessions: [] as Session[],
}) })
const refresh = async () => { const refresh = async () => {
@@ -6,7 +6,7 @@ import {
parseDeepLink, parseDeepLink,
parseNewSessionDeepLink, parseNewSessionDeepLink,
} from "./deep-links" } from "./deep-links"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { Session } from "@/types"
import { import {
childSessionOnPath, childSessionOnPath,
closeHomeProject, closeHomeProject,
@@ -25,18 +25,16 @@ import { ServerConnection } from "@/context/server"
const serverKey = ServerConnection.Key.make const serverKey = ServerConnection.Key.make
const session = (input: Partial<SessionInfo> & Pick<SessionInfo, "id"> & { directory: string }) => const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
({ ({
projectID: "project",
title: "", title: "",
cost: 0, version: "v2",
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,
location: { directory: input.directory }, }) as Session
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 { SessionInfo } from "@opencode-ai/client/promise" import type { Session } from "@/types"
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?: SessionInfo[] session?: Session[]
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: SessionInfo, b: SessionInfo) => { return (a: Session, b: Session) => {
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: SessionInfo, directory: string) => const isRootVisibleSession = (session: Session, directory: string) =>
pathKey(session.location.directory) === pathKey(directory) && !session.parentID && !session.time.archived pathKey(session.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: SessionInfo[] | undefined, rootID: string, activeID?: string) => { export const childSessionOnPath = (sessions: Session[] | 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: SessionInfo, session: Session,
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.location.directory) const directory = pathKey(session.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),
+15 -19
View File
@@ -1,4 +1,4 @@
import type { SessionInfo } from "@opencode-ai/client/promise" import type { Session } from "@/types"
import { Avatar } from "@opencode-ai/ui/avatar" import { Avatar } from "@opencode-ai/ui/avatar"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
@@ -14,7 +14,7 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent" import { messageAgentColor } from "@/utils/agent"
import { sessionLabel } from "@/utils/session-title" import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree" import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers" import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
@@ -35,7 +35,7 @@ export const ProjectIcon = (props: {
const hasPermissions = createMemo(() => const hasPermissions = createMemo(() =>
dirs().some((directory) => { dirs().some((directory) => {
return hasProjectPermissions(serverSync().session.data.permission, (item) => { return hasProjectPermissions(serverSync().session.data.permission, (item) => {
if (serverSync().session.get(item.sessionID)?.location.directory !== directory) return false if (serverSync().session.get(item.sessionID)?.directory !== directory) return false
return !permission.autoResponds(item, directory) return !permission.autoResponds(item, directory)
}) })
}), }),
@@ -74,9 +74,9 @@ export const ProjectIcon = (props: {
} }
export type SessionItemProps = { export type SessionItemProps = {
session: SessionInfo session: Session
list: SessionInfo[] list: Session[]
navList?: Accessor<SessionInfo[]> navList?: Accessor<Session[]>
slug: string slug: string
mobile?: boolean mobile?: boolean
dense?: boolean dense?: boolean
@@ -85,12 +85,12 @@ export type SessionItemProps = {
level?: number level?: number
sidebarExpanded: Accessor<boolean> sidebarExpanded: Accessor<boolean>
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: SessionInfo) => Promise<void> archiveSession: (session: Session) => Promise<void>
} }
const SessionRow = (props: { const SessionRow = (props: {
session: SessionInfo session: Session
slug: string slug: string
mobile?: boolean mobile?: boolean
dense?: boolean dense?: boolean
@@ -104,7 +104,7 @@ const SessionRow = (props: {
warmPress: () => void warmPress: () => void
warmFocus: () => void warmFocus: () => void
}): JSX.Element => { }): JSX.Element => {
const title = () => sessionLabel(props.session) const title = () => sessionTitle(props.session.title)
return ( return (
<A <A
@@ -152,14 +152,14 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
const serverSync = useServerSync() const serverSync = useServerSync()
const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id)) const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id)) const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
const [sessionStore] = serverSync().child(props.session.location.directory) const [sessionStore] = serverSync().child(props.session.directory)
const hasPermissions = createMemo(() => { const hasPermissions = createMemo(() => {
return !!sessionPermissionRequest( return !!sessionPermissionRequest(
sessionStore.session, sessionStore.session,
serverSync().session.data.permission, serverSync().session.data.permission,
props.session.id, props.session.id,
(item) => { (item) => {
return !permission.autoResponds(item, props.session.location.directory) return !permission.autoResponds(item, props.session.directory)
}, },
) )
}) })
@@ -179,17 +179,13 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
const warm = (span: number, priority: "high" | "low") => { const warm = (span: number, priority: "high" | "low") => {
const nav = props.navList?.() const nav = props.navList?.()
const list = nav?.some( const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
(item) => item.id === props.session.id && item.location.directory === props.session.location.directory,
)
? nav ? nav
: props.list : props.list
props.prefetchSession(props.session, priority) props.prefetchSession(props.session, priority)
const idx = list.findIndex( const idx = list.findIndex((item) => item.id === props.session.id && item.directory === props.session.directory)
(item) => item.id === props.session.id && item.location.directory === props.session.location.directory,
)
if (idx === -1) return if (idx === -1) return
for (let step = 1; step <= span; step++) { for (let step = 1; step <= span; step++) {
@@ -233,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
fallback={ fallback={
<Tooltip <Tooltip
placement={props.mobile ? "bottom" : "right"} placement={props.mobile ? "bottom" : "right"}
value={sessionLabel(props.session)} value={sessionTitle(props.session.title)}
gutter={10} gutter={10}
class="min-w-0 w-full" class="min-w-0 w-full"
> >
@@ -305,7 +305,7 @@ export const SortableProject = (props: {
const isWorking = createMemo(() => const isWorking = createMemo(() =>
dirs().some((directory) => { dirs().some((directory) => {
return Object.keys(serverSync().session.data.session_status).some((id) => { return Object.keys(serverSync().session.data.session_status).some((id) => {
if (serverSync().session.get(id)?.location.directory !== directory) return false if (serverSync().session.get(id)?.directory !== directory) return false
return serverSync().session.data.session_working(id) return serverSync().session.data.session_working(id)
}) })
}), }),
@@ -14,7 +14,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { Session } from "@/types"
import { type LocalProject } from "@/context/layout" import { type LocalProject } from "@/context/layout"
import { useServerSync, useQueryOptions } from "@/context/server-sync" import { useServerSync, useQueryOptions } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -36,12 +36,12 @@ type InlineEditorComponent = (props: {
export type WorkspaceSidebarContext = { export type WorkspaceSidebarContext = {
currentDir: Accessor<string> currentDir: Accessor<string>
navList: Accessor<SessionInfo[]> navList: Accessor<Session[]>
sidebarExpanded: Accessor<boolean> sidebarExpanded: Accessor<boolean>
sidebarHovering: Accessor<boolean> sidebarHovering: Accessor<boolean>
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: SessionInfo) => Promise<void> archiveSession: (session: Session) => Promise<void>
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
editorOpen: (id: string) => boolean editorOpen: (id: string) => boolean
@@ -243,7 +243,7 @@ const WorkspaceSessionList = (props: {
ctx: WorkspaceSidebarContext ctx: WorkspaceSidebarContext
showNew: Accessor<boolean> showNew: Accessor<boolean>
loading: Accessor<boolean> loading: Accessor<boolean>
sessions: Accessor<SessionInfo[]> sessions: Accessor<Session[]>
hasMore: Accessor<boolean> hasMore: Accessor<boolean>
loadMore: () => Promise<void> loadMore: () => Promise<void>
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
+5 -6
View File
@@ -1,5 +1,4 @@
import type { FilePart, Project, UserMessage } from "@/types" import type { FilePart, Project, UserMessage, VcsFileDiff } 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"
@@ -160,7 +159,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.location.directory) const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.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.
@@ -253,7 +252,7 @@ function ResolvedTargetSessionRoute() {
() => params.id, () => params.id,
() => sync().session.lineage, () => sync().session.lineage,
) )
const directory = createMemo(() => current()?.session.location.directory) const directory = createMemo(() => current()?.session.directory)
const targetDirectory = () => directory()! const targetDirectory = () => directory()!
createEffect(() => { createEffect(() => {
@@ -719,13 +718,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<FileDiffInfo | undefined> => { const loadReviewDiff = async (file: string, version?: number): Promise<VcsFileDiff | 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: FileDiffInfo | undefined) => { const valid = (diff: VcsFileDiff | 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, SessionInfo } from "@opencode-ai/client/promise" import type { PermissionRequest, QuestionRequest, Session } from "@/types"
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 SessionInfo }) as Session
const permission = (id: string, sessionID: string) => const permission = (id: string, sessionID: string) =>
({ ({
@@ -1,7 +1,6 @@
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 { Todo } from "@/types" import type { PermissionRequest, QuestionRequest, 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 "@opencode-ai/client/promise" import type { PermissionRequest } from "@/types"
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.action}.description` const key = `settings.permissions.tool.${props.request.permission}.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.resources.length > 0}> <Show when={props.request.patterns.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.resources}> <For each={props.request.patterns}>
{(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 "@opencode-ai/client/promise" import type { QuestionAnswer, QuestionRequest } from "@/types"
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,8 +1,7 @@
import type { SessionInfo } from "@opencode-ai/client/promise" import type { PermissionRequest, QuestionRequest, Session } from "@/types"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
function sessionTreeRequest<T>( function sessionTreeRequest<T>(
session: SessionInfo[], session: Session[],
request: Record<string, T[] | undefined>, request: Record<string, T[] | undefined>,
sessionID?: string, sessionID?: string,
include: (item: T) => boolean = () => true, include: (item: T) => boolean = () => true,
@@ -35,7 +34,7 @@ function sessionTreeRequest<T>(
} }
export function sessionPermissionRequest( export function sessionPermissionRequest(
session: SessionInfo[], session: Session[],
request: Record<string, PermissionRequest[] | undefined>, request: Record<string, PermissionRequest[] | undefined>,
sessionID?: string, sessionID?: string,
include?: (item: PermissionRequest) => boolean, include?: (item: PermissionRequest) => boolean,
@@ -44,7 +43,7 @@ export function sessionPermissionRequest(
} }
export function sessionQuestionRequest( export function sessionQuestionRequest(
session: SessionInfo[], session: Session[],
request: Record<string, QuestionRequest[] | undefined>, request: Record<string, QuestionRequest[] | undefined>,
sessionID?: string, sessionID?: string,
include?: (item: QuestionRequest) => boolean, include?: (item: QuestionRequest) => boolean,
@@ -22,6 +22,8 @@ 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,5 +1,6 @@
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 {
@@ -14,7 +15,7 @@ import type { LineComment } from "@/context/comments"
export type DiffStyle = "unified" | "split" export type DiffStyle = "unified" | "split"
type ReviewDiff = FileDiffInfo type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
export interface SessionReviewTabProps { export interface SessionReviewTabProps {
title?: JSX.Element title?: JSX.Element
@@ -23,6 +23,7 @@ import { Mark } from "@opencode-ai/ui/logo"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
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 { SnapshotFileDiff, VcsFileDiff } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -56,8 +57,8 @@ import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab" import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
type ReviewDiff = FileDiffInfo type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
type RenderDiff = FileDiffInfo type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
function renderDiff(value: ReviewDiff): value is RenderDiff { function renderDiff(value: ReviewDiff): value is RenderDiff {
return typeof value.file === "string" return typeof value.file === "string"
@@ -298,7 +298,7 @@ export function MessageTimeline(props: {
}) })
const titleValue = createMemo(() => info()?.title) const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue())) const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = (): string | undefined => undefined const shareUrl = createMemo(() => info()?.share?.url)
// TODO: Restore these actions when the V2 client exposes session sharing. // TODO: Restore these actions when the V2 client exposes session sharing.
// const shareEnabled = createMemo(() => sync().data.config.share !== "disabled") // const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const shareEnabled = () => false const shareEnabled = () => false
@@ -1,6 +1,5 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { type Virtualizer } from "@tanstack/solid-virtual" import { type Virtualizer } from "@tanstack/solid-virtual"
import { Window } from "happy-dom"
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset" import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
test("matches only the scroll element or an ancestor containing it", () => { test("matches only the scroll element or an ancestor containing it", () => {
@@ -17,15 +16,14 @@ test("matches only the scroll element or an ancestor containing it", () => {
}) })
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => { test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
const targetWindow = new Window() const route = document.createElement("section")
const route = targetWindow.document.createElement("section") const viewport = document.createElement("div")
const viewport = targetWindow.document.createElement("div") const unrelated = document.createElement("div")
const unrelated = targetWindow.document.createElement("div")
route.append(viewport) route.append(viewport)
targetWindow.document.body.append(route) document.body.append(route)
const instance = { const instance = {
scrollElement: viewport, scrollElement: viewport,
targetWindow, targetWindow: window,
scrollOffset: 79_400, scrollOffset: 79_400,
options: { options: {
horizontal: false, horizontal: false,
@@ -40,24 +38,25 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
instance.scrollOffset = offset instance.scrollOffset = offset
}) })
targetWindow.document.body.append(unrelated) document.body.append(unrelated)
unrelated.remove() unrelated.remove()
await frames(2, targetWindow) await frames(2)
expect(calls).toEqual([]) expect(calls).toEqual([])
route.remove() route.remove()
targetWindow.document.body.append(route) document.body.append(route)
await waitFor(() => calls.length === 1, targetWindow) await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[0, false]]) expect(calls).toEqual([[0, false]])
route.remove() route.remove()
targetWindow.document.body.append(route) document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0)) await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3, targetWindow) await frames(3)
expect(calls).toEqual([[0, false]]) expect(calls).toEqual([[0, false]])
cleanup?.() cleanup?.()
await targetWindow.happyDOM.close() route.remove()
}) })
test("keeps checking until stale reset-delay callbacks can no longer win", async () => { test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
@@ -193,18 +192,8 @@ test("cleanup cancels reconnect checks and delegated offset observation", async
route.remove() route.remove()
}) })
type FrameWindow = { async function frames(count: number) {
requestAnimationFrame(callback: () => void): unknown
performance: { now(): number }
}
async function frames(count: number, targetWindow: FrameWindow = window) {
for (let index = 0; index < count; index++) { for (let index = 0; index < count; index++) {
await new Promise<void>((resolve) => targetWindow.requestAnimationFrame(() => resolve())) await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
} }
} }
async function waitFor(condition: () => boolean, targetWindow: FrameWindow = window) {
const deadline = targetWindow.performance.now() + 1_000
while (!condition() && targetWindow.performance.now() < deadline) await frames(1, targetWindow)
}
@@ -1,5 +1,5 @@
import type { SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise" import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { AssistantMessage, Message, Part, UserMessage } from "@/types" import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@/types"
import { createMemo, type Accessor } from "solid-js" import { createMemo, type Accessor } from "solid-js"
import { reuseTimelineRows } from "./row-reconciliation" import { reuseTimelineRows } from "./row-reconciliation"
import { Timeline, TimelineRow } from "./rows" import { Timeline, TimelineRow } from "./rows"
@@ -1,6 +1,6 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import type { SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise" import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { AssistantMessage, Part, UserMessage } from "@/types" import type { AssistantMessage, Part, SessionStatus, UserMessage } from "@/types"
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part" import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row" import { TimelineRow, type SummaryDiff } from "./timeline-row"
import { uniqueSummaryDiffs } from "./summary-diffs" import { uniqueSummaryDiffs } from "./summary-diffs"
@@ -1,24 +1,25 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { SnapshotFileDiff } from "@/types"
import { uniqueSummaryDiffs } from "./summary-diffs" import { uniqueSummaryDiffs } from "./summary-diffs"
const diff = (file: string, additions: number) => const diff = (file: string, additions: number) =>
({ ({
file, file,
patch: "",
additions, additions,
deletions: 0, deletions: 0,
status: "modified", }) satisfies SnapshotFileDiff
}) satisfies FileDiffInfo
describe("uniqueSummaryDiffs", () => { describe("uniqueSummaryDiffs", () => {
test("drops entries without files and preserves unique input", () => { test("drops entries without files and preserves unique input", () => {
const alpha = diff("alpha.ts", 1) const alpha = diff("alpha.ts", 1)
const beta = diff("beta.ts", 1) const beta = diff("beta.ts", 1)
const invalid = { additions: 1, deletions: 0 } satisfies SnapshotFileDiff
expect(uniqueSummaryDiffs(undefined)).toEqual([]) expect(uniqueSummaryDiffs(undefined)).toEqual([])
expect(uniqueSummaryDiffs([])).toEqual([]) expect(uniqueSummaryDiffs([])).toEqual([])
expect(uniqueSummaryDiffs([invalid])).toEqual([])
const result = uniqueSummaryDiffs([alpha, beta]) const result = uniqueSummaryDiffs([alpha, invalid, beta])
expect(result).toEqual([alpha, beta]) expect(result).toEqual([alpha, beta])
expect(result[0]).toBe(alpha) expect(result[0]).toBe(alpha)
expect(result[1]).toBe(beta) expect(result[1]).toBe(beta)
@@ -1,7 +1,7 @@
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { SnapshotFileDiff } from "@/types"
import type { SummaryDiff } from "./timeline-row" import type { SummaryDiff } from "./timeline-row"
export function uniqueSummaryDiffs(diffs: FileDiffInfo[] | undefined) { export function uniqueSummaryDiffs(diffs: SnapshotFileDiff[] | undefined) {
const files = new Set<string>() const files = new Set<string>()
return (diffs ?? []) return (diffs ?? [])
.reduceRight<SummaryDiff[]>((result, diff) => { .reduceRight<SummaryDiff[]>((result, diff) => {
@@ -15,6 +15,6 @@ export function uniqueSummaryDiffs(diffs: FileDiffInfo[] | undefined) {
.reverse() .reverse()
} }
function isSummaryDiff(diff: FileDiffInfo): diff is SummaryDiff { function isSummaryDiff(diff: SnapshotFileDiff): diff is SummaryDiff {
return typeof diff.file === "string" return typeof diff.file === "string"
} }
@@ -1,8 +1,8 @@
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { SnapshotFileDiff } from "@/types"
import type { PartGroup } from "@opencode-ai/session-ui/message-part" import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { Data, Equal } from "effect" import { Data, Equal } from "effect"
export type SummaryDiff = FileDiffInfo export type SummaryDiff = SnapshotFileDiff & { file: string }
export namespace TimelineRow { export namespace TimelineRow {
export class TurnGap extends Data.TaggedClass("TurnGap")<{ export class TurnGap extends Data.TaggedClass("TurnGap")<{
@@ -1,6 +1,6 @@
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { SessionStatus } from "@opencode-ai/client/promise" import type { SessionStatus } from "@/types"
import { onCleanup } from "solid-js" import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useSessionLayout } from "./session-layout" import { useSessionLayout } from "./session-layout"
@@ -187,7 +187,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return if (!sessionID) return
const existing = undefined const existing = info()?.share?.url
if (existing) { if (existing) {
await copyShare(existing, true) await copyShare(existing, true)
return return
@@ -4,8 +4,8 @@ import { filterReviewFiles, reviewDiffDirectory, reviewDiffKinds, reviewDiffNeed
describe("reviewDiffKinds", () => { describe("reviewDiffKinds", () => {
test("maps file and directory kinds", () => { test("maps file and directory kinds", () => {
const kinds = reviewDiffKinds([ const kinds = reviewDiffKinds([
{ file: "src/a.ts", patch: "", additions: 1, deletions: 0, status: "added" }, { file: "src/a.ts", additions: 1, deletions: 0, status: "added" },
{ file: "src/b.ts", patch: "", additions: 0, deletions: 2, status: "deleted" }, { file: "src/b.ts", additions: 0, deletions: 2, status: "deleted" },
]) ])
expect(kinds.get("src/a.ts")).toBe("add") expect(kinds.get("src/a.ts")).toBe("add")
@@ -14,9 +14,7 @@ describe("reviewDiffKinds", () => {
}) })
test("normalizes file and directory paths", () => { test("normalizes file and directory paths", () => {
const kinds = reviewDiffKinds([ const kinds = reviewDiffKinds([{ file: "\\src//lib/a.ts/", additions: 1, deletions: 1, status: "modified" }])
{ file: "\\src//lib/a.ts/", patch: "", additions: 1, deletions: 1, status: "modified" },
])
expect(kinds.get("src/lib/a.ts")).toBe("mix") expect(kinds.get("src/lib/a.ts")).toBe("mix")
expect(kinds.get("src/lib")).toBe("mix") expect(kinds.get("src/lib")).toBe("mix")
@@ -38,7 +36,6 @@ describe("reviewDiffNeedsLoad", () => {
file: "src/a.ts", file: "src/a.ts",
additions: 1, additions: 1,
deletions: 0, deletions: 0,
status: "modified",
patch: "diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts", patch: "diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts",
}), }),
).toBe(true) ).toBe(true)
@@ -50,13 +47,10 @@ describe("reviewDiffNeedsLoad", () => {
file: "src/a.ts", file: "src/a.ts",
additions: 1, additions: 1,
deletions: 0, deletions: 0,
status: "modified",
patch: "@@ -0,0 +1 @@\n+value", patch: "@@ -0,0 +1 @@\n+value",
}), }),
).toBe(false) ).toBe(false)
expect( expect(reviewDiffNeedsLoad({ file: "empty.txt", additions: 0, deletions: 0 })).toBe(false)
reviewDiffNeedsLoad({ file: "empty.txt", patch: "", additions: 0, deletions: 0, status: "modified" }),
).toBe(false)
}) })
}) })
@@ -1,14 +1,15 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Kind } from "@/components/file-tree-v2" import type { Kind } from "@/components/file-tree-v2"
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
export type RenderDiff = FileDiffInfo export type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
export function normalizePath(p: string) { export function normalizePath(p: string) {
return normalizeFileTreeV2Path(p) return normalizeFileTreeV2Path(p)
} }
export function filterRenderableDiff(value: FileDiffInfo): value is RenderDiff { export function filterRenderableDiff(value: FileDiffInfo | SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
return typeof value.file === "string" return typeof value.file === "string"
} }
@@ -1,4 +1,5 @@
import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js" import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
import type { SnapshotFileDiff, VcsFileDiff } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
@@ -30,7 +31,7 @@ import {
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2" import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
type ReviewDiff = FileDiffInfo type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
export type ReviewPanelV2Props = { export type ReviewPanelV2Props = {
title?: JSX.Element title?: JSX.Element
+44 -3
View File
@@ -1,11 +1,34 @@
import type { import type {
EventSubscribeOutput, EventSubscribeOutput,
FileDiffInfo, FileDiffInfo,
FileDiffLegacyInfo,
ProjectListOutput, ProjectListOutput,
QuestionAnswer,
QuestionInfo,
QuestionRequest,
ReferenceInfo,
SessionInfo,
SessionNotFoundError,
SessionStatus,
SessionV1Info,
SessionsResponse,
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export type {
QuestionAnswer,
QuestionRequest,
ReferenceInfo,
SessionNotFoundError,
SessionStatus,
}
export type Project = Omit<ProjectListOutput[number], "canonical"> & { worktree: string } export type Project = Omit<ProjectListOutput[number], "canonical"> & { worktree: string }
export type Session = Omit<SessionV1Info, "title"> & { title: string }
export type SessionV2Info = SessionInfo
export type V2SessionListResponse = SessionsResponse
export type SnapshotFileDiff = FileDiffLegacyInfo
export type VcsFileDiff = FileDiffInfo
type CurrentEvent = EventSubscribeOutput extends infer Item type CurrentEvent = EventSubscribeOutput extends infer Item
? Item extends { type: infer Type extends string; data: infer Data } ? Item extends { type: infer Type extends string; data: infer Data }
@@ -13,9 +36,21 @@ type CurrentEvent = EventSubscribeOutput extends infer Item
: never : never
: never : never
export type Event = CurrentEvent export type Event =
| Exclude<CurrentEvent, { type: "permission.asked" }>
| { type: "permission.asked"; properties: PermissionRequest }
export type EventSessionError = Extract<Event, { type: "session.execution.failed" }> export type EventSessionError = Extract<Event, { type: "session.error" }>
export type PermissionRequest = {
id: string
sessionID: string
permission: string
patterns: string[]
metadata: Record<string, unknown>
always: string[]
tool?: { messageID: string; callID: string }
}
type MessageError = type MessageError =
| { name: "ProviderAuthError"; data: { providerID: string; message: string } } | { name: "ProviderAuthError"; data: { providerID: string; message: string } }
@@ -43,7 +78,7 @@ export type UserMessage = {
role: "user" role: "user"
time: { created: number } time: { created: number }
format?: { type: "text" } | { type: "json_schema"; schema: Record<string, unknown>; retryCount?: number } format?: { type: "text" } | { type: "json_schema"; schema: Record<string, unknown>; retryCount?: number }
summary?: { title?: string; body?: string; diffs: FileDiffInfo[] } summary?: { title?: string; body?: string; diffs: SnapshotFileDiff[] }
agent: string agent: string
model: { providerID: string; modelID: string; variant?: string } model: { providerID: string; modelID: string; variant?: string }
system?: string system?: string
@@ -295,3 +330,9 @@ export type Config = {
experimental?: Record<string, unknown> experimental?: Record<string, unknown>
[key: string]: unknown [key: string]: unknown
} }
export type TextPartInput = Omit<TextPart, "id" | "sessionID" | "messageID"> & { id?: string }
export type FilePartInput = Omit<FilePart, "id" | "sessionID" | "messageID"> & { id?: string }
export type AgentPartInput = Omit<AgentPart, "id" | "sessionID" | "messageID"> & { id?: string }
export type Question = QuestionInfo
+2 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { SnapshotFileDiff } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Message } from "@/types" import type { Message } from "@/types"
import { diffs, message } from "./diffs" import { diffs, message } from "./diffs"
@@ -9,7 +10,7 @@ const item = {
additions: 1, additions: 1,
deletions: 1, deletions: 1,
status: "modified", status: "modified",
} satisfies FileDiffInfo } satisfies FileDiffInfo & SnapshotFileDiff
describe("diffs", () => { describe("diffs", () => {
test("keeps valid arrays", () => { test("keeps valid arrays", () => {
+2 -1
View File
@@ -1,7 +1,8 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Message } from "@/types" import type { Message } from "@/types"
type Diff = FileDiffInfo type Diff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
function diff(value: unknown): value is Diff { function diff(value: unknown): value is Diff {
if (!value || typeof value !== "object" || Array.isArray(value)) return false if (!value || typeof value !== "object" || Array.isArray(value)) return false
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { SessionNotFoundError } from "@opencode-ai/client/promise" import type { SessionNotFoundError } from "@/types"
import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors" import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors"
import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./server-errors" import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./server-errors"
-7
View File
@@ -1,12 +1,5 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
export function sessionLabel(session: Pick<SessionInfo, "title" | "parentID">) {
return displayLabel(session)
}
export function sessionTitle(title?: string) { export function sessionTitle(title?: string) {
if (!title) return title if (!title) return title
const match = title.match(pattern) const match = title.match(pattern)
+37 -1
View File
@@ -1,6 +1,42 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise" import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
import { listAllSessions } from "./session" import { listAllSessions, normalizeSessionInfo } from "./session"
describe("normalizeSessionInfo", () => {
test("adapts a current session to the app session shape", () => {
const result = normalizeSessionInfo({
id: "session-1",
projectID: "project-1",
agent: "build",
model: { id: "gpt-5", providerID: "openai", variant: "high" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: "New session",
location: { directory: "/repo/worktree", workspaceID: "workspace-1" },
subpath: "worktree",
revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot", files: [] },
} as SessionInfo)
expect(result).toEqual({
id: "session-1",
slug: "session-1",
projectID: "project-1",
workspaceID: "workspace-1",
directory: "/repo/worktree",
path: "worktree",
parentID: undefined,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
title: "New session",
agent: "build",
model: { id: "gpt-5", providerID: "openai", variant: "high" },
version: "",
time: { created: 1, updated: 1 },
revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot" },
})
})
})
describe("listAllSessions", () => { describe("listAllSessions", () => {
test("loads every page in server order and retains the query", async () => { test("loads every page in server order and retains the query", async () => {
+29 -2
View File
@@ -1,9 +1,36 @@
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise" import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
import type { Session } from "@/types"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
export function normalizeSessionInfo(input: SessionInfo | Session): Session {
if (!("location" in input)) return input
return {
id: input.id,
slug: input.id,
projectID: input.projectID,
workspaceID: input.location.workspaceID,
directory: input.location.directory,
path: input.subpath,
parentID: input.parentID,
cost: input.cost,
tokens: input.tokens,
title: withTimestampedFallback(input),
agent: input.agent,
model: input.model,
version: "",
time: input.time,
revert: input.revert && {
messageID: input.revert.messageID,
partID: input.revert.partID,
snapshot: input.revert.snapshot,
},
}
}
export async function listAllSessions(api: Pick<SessionApi, "list">, input: Omit<SessionListInput, "cursor">) { export async function listAllSessions(api: Pick<SessionApi, "list">, input: Omit<SessionListInput, "cursor">) {
const load = async (cursor?: string): Promise<SessionInfo[]> => { const load = async (cursor?: string): Promise<Session[]> => {
const result = await api.list({ ...input, limit: input.limit ?? 100, cursor }) const result = await api.list({ ...input, limit: input.limit ?? 100, cursor })
const sessions = result.data const sessions = result.data.map(normalizeSessionInfo)
if (result.data.length === 0 || !result.cursor.next) return sessions if (result.data.length === 0 || !result.cursor.next) return sessions
return [...sessions, ...(await load(result.cursor.next))] return [...sessions, ...(await load(result.cursor.next))]
} }
+1 -1
View File
@@ -61,7 +61,7 @@ for (const item of targets) {
name: "parcel-watcher-binding", name: "parcel-watcher-binding",
setup(build) { setup(build) {
build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({ build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({
contents: `export default () => require(${JSON.stringify(parcelWatcherPackage)})`, contents: `import binding from ${JSON.stringify(parcelWatcherPackage)}; export default () => binding`,
loader: "js", loader: "js",
})) }))
}, },
+38 -193
View File
@@ -1,4 +1,4 @@
import type { AgentSideConnection, PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk" import type { AgentSideConnection, PromptResponse } from "@agentclientprotocol/sdk"
import type { import type {
EventSubscribeOutput, EventSubscribeOutput,
OpenCodeClient, OpenCodeClient,
@@ -37,34 +37,6 @@ export type TurnStart =
| { readonly type: "skill"; readonly id: string } | { readonly type: "skill"; readonly id: string }
| { readonly type: "compaction"; readonly id: string } | { readonly type: "compaction"; readonly id: string }
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
export const ChildSessionUpdateMethod = "opencode/session/child_update"
type ChildSessionUpdateBase = {
readonly rootSessionId: string
readonly childSessionId: string
readonly parentSessionId: string
readonly depth: number
readonly title?: string
}
type ChildSessionEvent =
| { readonly type: "update"; readonly update: SessionUpdate }
| {
readonly type: "status"
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
readonly error?: { readonly type: string; readonly message: string }
}
export type ChildSessionUpdate = ChildSessionUpdateBase & ChildSessionEvent
type ChildSession = {
readonly id: string
readonly parentID: string
readonly depth: number
readonly title?: string
}
function emptyToolState(): ToolState { function emptyToolState(): ToolState {
return { name: "tool", input: {}, metadata: {}, content: [] } return { name: "tool", input: {}, metadata: {}, content: [] }
} }
@@ -78,13 +50,8 @@ export async function streamTurn(input: {
readonly writeTextFile: boolean readonly writeTextFile: boolean
readonly submit: (signal: AbortSignal) => Promise<unknown> readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly control: TurnControl readonly control: TurnControl
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
readonly connectionSignal?: AbortSignal
readonly sessionSignal?: AbortSignal
}): Promise<PromptResponse> { }): Promise<PromptResponse> {
const streamController = new AbortController() const streamController = new AbortController()
const connectionAbort = () => streamController.abort()
input.connectionSignal?.addEventListener("abort", connectionAbort, { once: true })
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]() const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
const connected = await stream.next() const connected = await stream.next()
if (connected.done) throw new Error("event stream disconnected before prompt admission") if (connected.done) throw new Error("event stream disconnected before prompt admission")
@@ -95,101 +62,47 @@ export async function streamTurn(input: {
let finish: SessionMessageAssistant["finish"] let finish: SessionMessageAssistant["finish"]
let executionError: { readonly type: string; readonly message: string } | undefined let executionError: { readonly type: string; readonly message: string } | undefined
const tools = new Map<string, ToolState>() const tools = new Map<string, ToolState>()
const children = new Map<string, ChildSession>()
const openChildren = new Set<string>()
let handedOff = false
const notifyChild = async (child: ChildSession, value: ChildSessionEvent) => { const update = (value: Parameters<Connection["sessionUpdate"]>[0]["update"]) =>
if (!input.childSessionUpdate) return input.connection.sessionUpdate({ sessionId: input.sessionID, update: value })
await input
.childSessionUpdate({
rootSessionId: input.sessionID,
childSessionId: child.id,
parentSessionId: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
...value,
})
.catch(() => {})
}
const updateSession = async (value: SessionUpdate, child: ChildSession | undefined, mode: "turn" | "background") => { const consume = async () => {
const projected = child ? projectChildUpdate(value, child) : value
if (mode === "turn" && (!child || !input.childSessionUpdate)) {
await input.connection.sessionUpdate({ sessionId: input.sessionID, update: projected })
}
if (child) await notifyChild(child, { type: "update", update: projected })
}
const consume = async (mode: "turn" | "background") => {
while (!streamController.signal.aborted) { while (!streamController.signal.aborted) {
const next = await stream.next() const next = await stream.next()
if (next.done) throw new Error("event stream disconnected during prompt execution") if (next.done) throw new Error("event stream disconnected during prompt execution")
const event = next.value const event = next.value
if (event.type === "session.created") { if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) {
const parentID = event.data.parentID const tool = event.data.source?.id ? tools.get(event.data.source.id) : undefined
if (!parentID) continue
const parent = parentID === input.sessionID ? undefined : children.get(parentID)
if ((mode === "turn" && parentID === input.sessionID) || parent) {
const child = {
id: event.data.sessionID,
parentID,
depth: parent ? parent.depth + 1 : 1,
title: event.data.title,
}
children.set(child.id, child)
openChildren.add(child.id)
await notifyChild(child, { type: "status", status: "created" })
}
continue
}
const eventSessionID = sessionIDFromEvent(event)
const child = eventSessionID ? children.get(eventSessionID) : undefined
const send = (update: SessionUpdate) => updateSession(update, child, mode)
if (mode === "background" && !child) continue
if (event.type === "permission.asked" && (event.data.sessionID === input.sessionID || child)) {
const tool = event.data.source?.id ? tools.get(toolKey(event.data.sessionID, event.data.source.id)) : undefined
await replyPermission({ await replyPermission({
client: input.client, client: input.client,
connection: input.connection, connection: input.connection,
event, event,
sessionID: event.data.sessionID, sessionID: input.sessionID,
clientSessionID: input.sessionID,
cwd: input.cwd, cwd: input.cwd,
tool, tool,
...(child ? { toolCallPrefix: child.id, titlePrefix: child.title } : {}),
}) })
continue continue
} }
if (event.type === "form.created" && (event.data.form.sessionID === input.sessionID || child)) { if (event.type === "form.created" && event.data.form.sessionID === input.sessionID) {
await input.client.form await input.client.form
.cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id }) .cancel({ sessionID: input.sessionID, formID: event.data.form.id })
.catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {})) .catch(() => input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}))
continue continue
} }
if (!eventSessionID || (eventSessionID !== input.sessionID && !child)) continue if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
if (matchesStart(event, input.start)) { if (matchesStart(event, input.start)) {
started = true started = true
continue continue
} }
if (!started) continue if (!started) continue
if (event.type === "session.execution.started") {
if (child) {
await notifyChild(child, { type: "status", status: "running" })
}
continue
}
if (event.type === "session.step.started") { if (event.type === "session.step.started") {
if (!child) assistantMessageID = event.data.assistantMessageID assistantMessageID = event.data.assistantMessageID
continue continue
} }
if (event.type === "session.text.delta") { if (event.type === "session.text.delta") {
if (!child) assistantMessageID = event.data.assistantMessageID assistantMessageID = event.data.assistantMessageID
await send({ await update({
sessionUpdate: "agent_message_chunk", sessionUpdate: "agent_message_chunk",
messageId: event.data.assistantMessageID, messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta }, content: { type: "text", text: event.data.delta },
@@ -197,8 +110,8 @@ export async function streamTurn(input: {
continue continue
} }
if (event.type === "session.reasoning.delta") { if (event.type === "session.reasoning.delta") {
if (!child) assistantMessageID = event.data.assistantMessageID assistantMessageID = event.data.assistantMessageID
await send({ await update({
sessionUpdate: "agent_thought_chunk", sessionUpdate: "agent_thought_chunk",
messageId: event.data.assistantMessageID, messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta }, content: { type: "text", text: event.data.delta },
@@ -206,14 +119,9 @@ export async function streamTurn(input: {
continue continue
} }
if (event.type === "session.tool.input.started") { if (event.type === "session.tool.input.started") {
if (!child) assistantMessageID = event.data.assistantMessageID assistantMessageID = event.data.assistantMessageID
tools.set(toolKey(event.data.sessionID, event.data.id), { tools.set(event.data.id, { name: event.data.name, input: {}, metadata: {}, content: [] })
name: event.data.name, await update({
input: {},
metadata: {},
content: [],
})
await send({
sessionUpdate: "tool_call", sessionUpdate: "tool_call",
...pendingToolCall({ ...pendingToolCall({
toolCallId: event.data.id, toolCallId: event.data.id,
@@ -225,12 +133,11 @@ export async function streamTurn(input: {
continue continue
} }
if (event.type === "session.tool.called") { if (event.type === "session.tool.called") {
if (!child) assistantMessageID = event.data.assistantMessageID assistantMessageID = event.data.assistantMessageID
const key = toolKey(event.data.sessionID, event.data.id) const current = tools.get(event.data.id) ?? emptyToolState()
const current = tools.get(key) ?? emptyToolState()
current.input = event.data.input current.input = event.data.input
tools.set(key, current) tools.set(event.data.id, current)
await send({ await update({
sessionUpdate: "tool_call_update", sessionUpdate: "tool_call_update",
...runningToolUpdate({ ...runningToolUpdate({
toolCallId: event.data.id, toolCallId: event.data.id,
@@ -242,10 +149,10 @@ export async function streamTurn(input: {
continue continue
} }
if (event.type === "session.tool.progress") { if (event.type === "session.tool.progress") {
const current = tools.get(toolKey(event.data.sessionID, event.data.id)) const current = tools.get(event.data.id)
if (!current) continue if (!current) continue
current.metadata = event.data.metadata current.metadata = event.data.metadata
await send({ await update({
sessionUpdate: "tool_call_update", sessionUpdate: "tool_call_update",
...runningToolUpdate({ ...runningToolUpdate({
toolCallId: event.data.id, toolCallId: event.data.id,
@@ -257,9 +164,8 @@ export async function streamTurn(input: {
continue continue
} }
if (event.type === "session.tool.success") { if (event.type === "session.tool.success") {
const key = toolKey(event.data.sessionID, event.data.id) const current = tools.get(event.data.id) ?? emptyToolState()
const current = tools.get(key) ?? emptyToolState() tools.delete(event.data.id)
tools.delete(key)
await syncEditedFiles({ await syncEditedFiles({
connection: input.connection, connection: input.connection,
writeTextFile: input.writeTextFile, writeTextFile: input.writeTextFile,
@@ -269,7 +175,7 @@ export async function streamTurn(input: {
toolInput: current.input, toolInput: current.input,
metadata: event.data.metadata ?? {}, metadata: event.data.metadata ?? {},
}).catch(() => {}) }).catch(() => {})
await send({ await update({
sessionUpdate: "tool_call_update", sessionUpdate: "tool_call_update",
...completedToolUpdate({ ...completedToolUpdate({
toolCallId: event.data.id, toolCallId: event.data.id,
@@ -282,10 +188,9 @@ export async function streamTurn(input: {
continue continue
} }
if (event.type === "session.tool.failed") { if (event.type === "session.tool.failed") {
const key = toolKey(event.data.sessionID, event.data.id) const current = tools.get(event.data.id) ?? emptyToolState()
const current = tools.get(key) ?? emptyToolState() tools.delete(event.data.id)
tools.delete(key) await update({
await send({
sessionUpdate: "tool_call_update", sessionUpdate: "tool_call_update",
...errorToolUpdate({ ...errorToolUpdate({
toolCallId: event.data.id, toolCallId: event.data.id,
@@ -300,33 +205,13 @@ export async function streamTurn(input: {
continue continue
} }
if (event.type === "session.step.ended") { if (event.type === "session.step.ended") {
if (!child) { assistantMessageID = event.data.assistantMessageID
assistantMessageID = event.data.assistantMessageID finish = event.data.finish
finish = event.data.finish
}
continue
}
if (event.type === "session.execution.succeeded") {
if (!child) return "succeeded" as const
openChildren.delete(child.id)
await notifyChild(child, { type: "status", status: "completed" })
if (mode === "background" && openChildren.size === 0) return "succeeded" as const
continue
}
if (event.type === "session.execution.interrupted") {
if (!child) return "interrupted" as const
openChildren.delete(child.id)
await notifyChild(child, { type: "status", status: "interrupted" })
if (mode === "background" && openChildren.size === 0) return "interrupted" as const
continue continue
} }
if (event.type === "session.execution.succeeded") return "succeeded" as const
if (event.type === "session.execution.interrupted") return "interrupted" as const
if (event.type === "session.execution.failed") { if (event.type === "session.execution.failed") {
if (child) {
openChildren.delete(child.id)
await notifyChild(child, { type: "status", status: "failed", error: event.data.error })
if (mode === "background" && openChildren.size === 0) return "failed" as const
continue
}
executionError = event.data.error executionError = event.data.error
return "failed" as const return "failed" as const
} }
@@ -334,13 +219,7 @@ export async function streamTurn(input: {
return "interrupted" as const return "interrupted" as const
} }
const completed = consume("turn") const completed = consume()
const closeStream = async () => {
streamController.abort()
input.connectionSignal?.removeEventListener("abort", connectionAbort)
input.sessionSignal?.removeEventListener("abort", connectionAbort)
await stream.return?.(undefined).catch(() => {})
}
try { try {
await input.submit(control.admission.signal).catch((error) => { await input.submit(control.admission.signal).catch((error) => {
if (!control.cancelled) throw error if (!control.cancelled) throw error
@@ -354,13 +233,6 @@ export async function streamTurn(input: {
} }
} }
const terminal = await completed const terminal = await completed
if (input.childSessionUpdate && openChildren.size > 0 && !input.sessionSignal?.aborted) {
handedOff = true
input.sessionSignal?.addEventListener("abort", connectionAbort, { once: true })
void consume("background")
.catch(() => {})
.finally(closeStream)
}
const assistant = assistantMessageID const assistant = assistantMessageID
? await input.client.session ? await input.client.session
.message({ sessionID: input.sessionID, messageID: assistantMessageID }) .message({ sessionID: input.sessionID, messageID: assistantMessageID })
@@ -378,38 +250,11 @@ export async function streamTurn(input: {
await completed.catch(() => {}) await completed.catch(() => {})
throw error throw error
} finally { } finally {
if (!handedOff) await closeStream() streamController.abort()
await stream.return?.(undefined).catch(() => {})
} }
} }
function sessionIDFromEvent(event: EventSubscribeOutput) {
if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID
if (event.type === "form.created") return event.data.form.sessionID
return undefined
}
function toolKey(sessionID: string, id: string) {
return `${sessionID}:${id}`
}
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
const projected = { ...update }
projected._meta = {
...projected._meta,
"opencode/child-session": {
id: child.id,
parentID: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
},
}
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
projected.toolCallId = `${child.id}:${projected.toolCallId}`
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
}
return projected
}
export async function replayMessages( export async function replayMessages(
connection: Pick<AgentSideConnection, "sessionUpdate">, connection: Pick<AgentSideConnection, "sessionUpdate">,
sessionID: string, sessionID: string,
+3 -17
View File
@@ -20,28 +20,20 @@ export async function replyPermission(input: {
readonly connection: Connection readonly connection: Connection
readonly event: PermissionEvent readonly event: PermissionEvent
readonly sessionID: string readonly sessionID: string
readonly clientSessionID?: string
readonly cwd: string readonly cwd: string
readonly tool?: Tool readonly tool?: Tool
readonly toolCallPrefix?: string
readonly titlePrefix?: string
}) { }) {
const toolName = input.tool?.name ?? input.event.data.action const toolName = input.tool?.name ?? input.event.data.action
const toolInput = { ...input.event.data.metadata, ...input.tool?.input } const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
const previews = await permissionPreviews(toolName, toolInput, input.cwd) const previews = await permissionPreviews(toolName, toolInput, input.cwd)
const toolCallID = input.event.data.source?.id ?? input.event.data.id
const title = permissionTitle(toolName, toolInput, previews)
const result = await input.connection const result = await input.connection
.requestPermission({ .requestPermission({
sessionId: input.clientSessionID ?? input.sessionID, sessionId: input.sessionID,
toolCall: { toolCall: {
...pendingToolCall({ ...pendingToolCall({
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID, toolCallId: input.event.data.source?.id ?? input.event.data.id,
toolName, toolName,
state: { state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) },
input: toolInput,
title: prefixedTitle(input.titlePrefix, title),
},
cwd: input.cwd, cwd: input.cwd,
}), }),
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews), locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
@@ -59,12 +51,6 @@ export async function replyPermission(input: {
}) })
} }
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
if (!prefix) return title
if (!title) return prefix
return `${prefix}: ${title}`
}
export async function syncEditedFiles(input: { export async function syncEditedFiles(input: {
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">> readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
readonly writeTextFile: boolean readonly writeTextFile: boolean

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