Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 32e2e3f2f7 feat(tui): surface plugin failures 2026-08-12 02:43:52 +00:00
919 changed files with 9680 additions and 168548 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
-1
View File
@@ -10,7 +10,6 @@ kitlangton
kommander kommander
ludvigrask ludvigrask
MrMushrooooom MrMushrooooom
neriousy
nexxeln nexxeln
R44VC0RP R44VC0RP
rekram1-node rekram1-node
-49
View File
@@ -1,49 +0,0 @@
name: deploy-lab-catalog
on:
push:
branches: [v2]
paths:
- ".github/workflows/deploy-lab-catalog.yml"
- "bun.lock"
- "package.json"
- "packages/drive/**"
- "packages/protocol/src/simulation.ts"
- "packages/simulation/**"
- "packages/lab/catalog/**"
workflow_dispatch:
concurrency:
group: deploy-lab-catalog-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: ./.github/actions/setup-bun
- name: Install ffmpeg
run: |
sudo apt-get update
sudo apt-get install --yes ffmpeg
- name: Validate
run: |
bun --cwd packages/protocol typecheck
bun --cwd packages/simulation typecheck
bun --cwd packages/drive run check
bun --cwd packages/drive run test
bun --cwd packages/lab/catalog run check
- name: Deploy
working-directory: packages/lab/catalog
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1
View File
@@ -6,6 +6,7 @@ on:
branches: branches:
- ci - ci
- dev - dev
- v2
- beta - beta
- fix/npm-native-binary-install - fix/npm-native-binary-install
- snapshot-* - snapshot-*
+1 -7
View File
@@ -55,12 +55,6 @@ jobs:
git config --global user.email "bot@opencode.ai" git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode" git config --global user.name "opencode"
- name: Install ffmpeg
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install --yes ffmpeg
- name: Cache Turbo - name: Cache Turbo
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with: with:
@@ -72,7 +66,7 @@ jobs:
- name: Run unit tests - name: Run unit tests
timeout-minutes: 20 timeout-minutes: 20
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }} run: GITHUB_ACTIONS=false bun turbo test
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
+4 -4
View File
@@ -51,7 +51,7 @@ files. If the script is unsuccessful, automatically fix the script and run it ag
Scripts use one typed definition object. `setup` runs before OpenCode starts, Scripts use one typed definition object. `setup` runs before OpenCode starts,
and `fs.writeFile` always writes inside the simulated project. and `fs.writeFile` always writes inside the simulated project.
You can read the full typed API here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/src/script/types.ts You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
```ts ```ts
import { defineScript } from "opencode-drive" import { defineScript } from "opencode-drive"
@@ -83,7 +83,7 @@ itself (this is extremely rare, do not use this unless explicitly asked). In thi
mode `ui` is typed as `null`; call `server.launch()` exactly mode `ui` is typed as `null`; call `server.launch()` exactly
once before launching clients. Each `clients.launch(name)` result provides the once before launching clients. Each `clients.launch(name)` result provides the
same UI methods as the automatic client. You can see an example of this API same UI methods as the automatic client. You can see an example of this API
here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/multiple-clients.ts here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
Use the exported `wait(milliseconds)` utility for an unconditional delay. Use the exported `wait(milliseconds)` utility for an unconditional delay.
@@ -114,8 +114,8 @@ completion are automatic.
You can see some example scripts here: You can see some example scripts here:
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/simple.ts - https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/serve.ts - https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
## Prune ## Prune
+1 -1
View File
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
const TEAM = { const TEAM = {
tui: ["kommander", "simonklee"], tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"], desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "neriousy", "nexxeln", "kitlangton"], core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
inference: ["fwang", "MrMushrooooom", "starptech"], inference: ["fwang", "MrMushrooooom", "starptech"],
windows: ["Hona"], windows: ["Hona"],
} as const } as const
+3 -4
View File
@@ -1,9 +1,8 @@
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly. - After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server. - Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required. - Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- The default branch in this repo is `v2`. - The default branch in this repo is `dev`.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
## Live V2 TUI Testing ## Live V2 TUI Testing
@@ -171,4 +170,4 @@ const table = sqliteTable("session", {
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle. - One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership. - Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry. - Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Unavailable sources retain the last value and block only the initial complete delta. - `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement and committed revert clear it. Unavailable sources retain the last value and block only the initial complete delta.
+525 -1074
View File
File diff suppressed because it is too large Load Diff
+7 -10
View File
@@ -33,7 +33,6 @@
"packages": [ "packages": [
"packages/*", "packages/*",
"packages/console/*", "packages/console/*",
"packages/lab/*",
"packages/stats/*", "packages/stats/*",
"packages/slack" "packages/slack"
], ],
@@ -47,9 +46,9 @@
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0", "@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.2", "@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.5.2", "@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.5.2", "@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@tanstack/solid-virtual": "3.13.32", "@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0", "@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0", "@standard-schema/spec": "1.1.0",
@@ -121,8 +120,7 @@
"prettier": "3.6.2", "prettier": "3.6.2",
"semver": "^7.6.0", "semver": "^7.6.0",
"sst": "catalog:", "sst": "catalog:",
"turbo": "2.10.2", "turbo": "2.10.2"
"vitest": "4.1.10"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.933.0", "@aws-sdk/client-s3": "3.933.0",
@@ -152,9 +150,9 @@
"electron" "electron"
], ],
"overrides": { "overrides": {
"@opentui/core": "catalog:", "@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "catalog:", "@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "catalog:", "@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"effect": "catalog:" "effect": "catalog:"
@@ -174,7 +172,6 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch", "effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@cloudflare/vitest-pool-workers@0.12.6": "patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch",
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch" "@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
} }
} }
@@ -573,10 +573,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
cache_control: cacheControl(breakpoints, part.cache), cache_control: cacheControl(breakpoints, part.cache),
}) })
} }
const previous = messages.at(-1) messages.push({ role: "user", content })
if (previous?.role === "user" && previous.content.every((block) => block.type === "tool_result"))
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
else messages.push({ role: "user", content })
} }
return messages return messages
+30 -73
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Effect, Layer, Option, Schema, Stream } from "effect" import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
import { import {
FetchHttpClient, FetchHttpClient,
Headers, Headers,
@@ -297,86 +297,44 @@ export const classifyHttpFailure = (input: {
}) })
} }
type HttpOperation = "request" | "read" const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
const transportError = (input: {
const NativeTransportFailure = Schema.Struct({ readonly message: string
message: Schema.String, readonly kind?: string | undefined
code: Schema.optionalKey(Schema.String), readonly request?: HttpClientRequest.HttpClientRequest | undefined
cause: Schema.optionalKey(Schema.Unknown), }) =>
})
const decodeNativeTransportFailure = Schema.decodeUnknownOption(NativeTransportFailure)
const nativeTransportFailure = (error: unknown) => {
const failure = Option.getOrUndefined(decodeNativeTransportFailure(error))
if (!failure) return undefined
if (failure.code !== undefined) return failure
const cause = Option.getOrUndefined(decodeNativeTransportFailure(failure.cause))
if (cause?.code !== undefined) return cause
return failure
}
const httpError = (input: {
readonly error: unknown
readonly request: HttpClientRequest.HttpClientRequest
readonly operation: HttpOperation
readonly redactedNames: ReadonlyArray<string | RegExp>
}) => {
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
new AIError({ new AIError({
module: "RequestExecutor", module: "RequestExecutor",
method: input.operation, method: "execute",
reason: new TransportReason({ reason: new TransportReason({
message: failure.message, message: input.message,
transport: "http", kind: input.kind,
operation: input.operation, url: input.request ? redactUrl(input.request.url) : undefined,
code: failure.code, http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
url: redactUrl(request.url),
http: new HttpContext({ request: requestDetails(request, input.redactedNames) }),
}), }),
}) })
const source = if (Cause.isTimeoutError(error)) {
HttpClientError.isHttpClientError(input.error) && "cause" in input.error.reason return transportError({ message: error.message, kind: "Timeout" })
? input.error.reason.cause }
: input.error if (!HttpClientError.isHttpClientError(error)) {
const native = nativeTransportFailure(source) return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" })
const code = native?.code }
const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined) const request = "request" in error ? error.request : undefined
const detail = raw ? redactBody(raw, secretValues(request)) : undefined if (error.reason._tag === "TransportError") {
const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail
if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source))
return transportError({ message: message ?? "HTTP transport timed out", code: code ?? "Timeout" })
if (!HttpClientError.isHttpClientError(input.error))
return transportError({ message: message ?? "HTTP transport failed", code })
if (input.error.reason._tag === "TransportError") {
return transportError({ return transportError({
message: message ?? input.error.reason.description ?? "HTTP transport failed", message: error.reason.description ?? "HTTP transport failed",
code: code ?? input.error.reason._tag, kind: error.reason._tag,
request,
}) })
} }
return transportError({ return transportError({
message: message ?? `HTTP transport failed: ${input.error.reason._tag}`, message: `HTTP transport failed: ${error.reason._tag}`,
code: code ?? input.error.reason._tag, kind: error.reason._tag,
request,
}) })
} }
export const stream = (
executor: Interface,
request: HttpClientRequest.HttpClientRequest,
middleware?: HttpMiddleware,
): Stream.Stream<Uint8Array, AIError> =>
Stream.unwrap(
Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
const response = yield* executor.execute(request, middleware)
return response.stream.pipe(
Stream.mapError((error) => httpError({ error, request: response.request, operation: "read", redactedNames })),
)
}),
)
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.effect( export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -385,16 +343,15 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
Effect.gen(function* () { Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames const redactedNames = yield* Headers.CurrentRedactedNames
if (!middleware) if (!middleware)
return yield* http.execute(request).pipe( return yield* http
Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })), .execute(request)
Effect.flatMap(statusError(request, redactedNames)), .pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
)
const response = yield* middleware(request, (input) => const response = yield* middleware(request, (input) =>
http http
.execute(input) .execute(input)
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))), .pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames }))) ).pipe(Effect.mapError(toHttpError(redactedNames)))
return yield* statusError(response.request, redactedNames)(response) return yield* statusError(response.request, redactedNames)(response)
}) })
return Service.of({ return Service.of({
+21 -4
View File
@@ -1,4 +1,4 @@
import { Effect } from "effect" import { Effect, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http" import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Auth } from "../auth.js" import { Auth } from "../auth.js"
import { render as renderEndpoint } from "../endpoint.js" import { render as renderEndpoint } from "../endpoint.js"
@@ -6,7 +6,6 @@ import { Framing } from "../framing.js"
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index.js" import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index.js"
import * as ProviderShared from "../../protocols/shared.js" import * as ProviderShared from "../../protocols/shared.js"
import { mergeJsonRecords, type LLMRequest } from "../../schema/index.js" import { mergeJsonRecords, type LLMRequest } from "../../schema/index.js"
import { RequestExecutor } from "../executor.js"
export type JsonRequestInput<Body> = TransportPrepareInput<Body> export type JsonRequestInput<Body> = TransportPrepareInput<Body>
@@ -87,8 +86,26 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
middleware: prepareInput.middleware, middleware: prepareInput.middleware,
} }
}), }),
frames: (prepared, _request, runtime) => frames: (prepared, request, runtime) =>
prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)), Stream.unwrap(
runtime.http
.execute(prepared.request, prepared.middleware)
.pipe(
Effect.map((response) =>
prepared.framing.frame(
response.stream.pipe(
Stream.mapError((error) =>
ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
ProviderShared.errorText(error),
),
),
),
),
),
),
),
}) })
export const sseJson = { export const sseJson = {
+13 -36
View File
@@ -1,6 +1,6 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect" import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http" import { Headers } from "effect/unstable/http"
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js" import { AIError, TransportReason } from "../../schema/index.js"
import * as HttpTransport from "./http.js" import * as HttpTransport from "./http.js"
import type { Transport } from "./index.js" import type { Transport } from "./index.js"
@@ -29,18 +29,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AI
const transportError = ( const transportError = (
method: string, method: string,
message: string, message: string,
input: { readonly operation: TransportOperation; readonly url?: string; readonly code?: string }, input: { readonly url?: string; readonly kind?: string } = {},
) => ) =>
new AIError({ new AIError({
module: "WebSocketExecutor", module: "WebSocketExecutor",
method, method,
reason: new TransportReason({ reason: new TransportReason({ message, url: input.url, kind: input.kind }),
message,
transport: "websocket",
operation: input.operation,
url: input.url,
code: input.code,
}),
}) })
const eventMessage = (event: Event) => { const eventMessage = (event: Event) => {
@@ -61,8 +55,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
return Effect.fail( return Effect.fail(
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, { transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
url: input.url, url: input.url,
operation: "request", kind: "open",
code: "closed",
}), }),
) )
} }
@@ -86,10 +79,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
cleanup() cleanup()
resume( resume(
Effect.fail( Effect.fail(
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { url: input.url, kind: "open" }),
url: input.url,
operation: "request",
}),
), ),
) )
} }
@@ -99,8 +89,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
Effect.fail( Effect.fail(
transportError("open", `WebSocket closed before opening with code ${event.code}`, { transportError("open", `WebSocket closed before opening with code ${event.code}`, {
url: input.url, url: input.url,
operation: "request", kind: "open",
code: String(event.code),
}), }),
), ),
) )
@@ -129,8 +118,7 @@ const webSocketUrl = (value: string) =>
catch: (error) => catch: (error) =>
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", { transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
url: value, url: value,
operation: "request", kind: "websocket",
code: "invalid-url",
}), }),
}) })
@@ -141,7 +129,7 @@ export const open = (input: WebSocketRequest) =>
catch: (error) => catch: (error) =>
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", { transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
url: input.url, url: input.url,
operation: "request", kind: "open",
}), }),
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input))) }).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
@@ -162,10 +150,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe( Queue.failCauseUnsafe(
messages, messages,
Cause.fail( Cause.fail(
transportError("message", "Unsupported WebSocket message payload", { transportError("message", "Unsupported WebSocket message payload", { url: input.url, kind: "message" }),
url: input.url,
operation: "read",
}),
), ),
) )
} }
@@ -173,10 +158,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe( Queue.failCauseUnsafe(
messages, messages,
Cause.fail( Cause.fail(
transportError("message", `WebSocket error: ${eventMessage(event)}`, { transportError("message", `WebSocket error: ${eventMessage(event)}`, { url: input.url, kind: "message" }),
url: input.url,
operation: "read",
}),
), ),
) )
} }
@@ -185,11 +167,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe( Queue.failCauseUnsafe(
messages, messages,
Cause.fail( Cause.fail(
transportError("message", `WebSocket closed with code ${event.code}`, { transportError("message", `WebSocket closed with code ${event.code}`, { url: input.url, kind: "close" }),
url: input.url,
operation: "read",
code: String(event.code),
}),
), ),
) )
} }
@@ -210,7 +188,7 @@ export const fromWebSocket = (
catch: (error) => catch: (error) =>
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", { transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
url: input.url, url: input.url,
operation: "write", kind: "write",
}), }),
}), }),
messages: Stream.fromQueue(messages), messages: Stream.fromQueue(messages),
@@ -265,8 +243,7 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
return Stream.fail( return Stream.fail(
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", { transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
url: prepared.url, url: prepared.url,
operation: "request", kind: "websocket",
code: "unavailable",
}), }),
) )
} }
+1 -9
View File
@@ -92,18 +92,10 @@ export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
export const TransportType = Schema.Literals(["http", "websocket"])
export type TransportType = typeof TransportType.Type
export const TransportOperation = Schema.Literals(["request", "read", "write"])
export type TransportOperation = typeof TransportOperation.Type
export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({ export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
_tag: Schema.tag("Transport"), _tag: Schema.tag("Transport"),
message: Schema.String, message: Schema.String,
transport: TransportType, kind: Schema.optional(Schema.String),
operation: TransportOperation,
code: Schema.optional(Schema.String),
url: Schema.optional(Schema.String), url: Schema.optional(Schema.String),
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
+3 -101
View File
@@ -1,10 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer, Ref, Stream } from "effect" import { Effect, Layer, Ref } from "effect"
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, AIError } from "../src/index.js" import { LLM, AIError } from "../src/index.js"
import { LLMClient, RequestExecutor } from "../src/route.js" import { LLMClient, RequestExecutor } from "../src/route.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js" import * as OpenAIChat from "../src/protocols/openai-chat.js"
import { dynamicResponse, systemError } from "./lib/http.js" import { dynamicResponse } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js" import { deltaChunk } from "./lib/openai-chunks.js"
import { sseRaw } from "./lib/sse.js" import { sseRaw } from "./lib/sse.js"
import { it } from "./lib/effect.js" import { it } from "./lib/effect.js"
@@ -67,62 +67,6 @@ const expectAIError = (error: unknown) => {
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined) const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
describe("RequestExecutor", () => { describe("RequestExecutor", () => {
it.effect("parses response body failures at the executor seam", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: disconnected <redacted> <redacted>",
transport: "http",
operation: "read",
code: "ECONNRESET",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&debug=1",
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
),
]),
),
),
)
it.effect("unwraps native transport failure causes", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed",
operation: "read",
code: "ECONNRESET",
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
),
]),
),
),
)
it.effect("preserves middleware error messages", () => it.effect("preserves middleware error messages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
@@ -135,48 +79,6 @@ describe("RequestExecutor", () => {
}).pipe(Effect.provide(responsesLayer([]))), }).pipe(Effect.provide(responsesLayer([]))),
) )
it.effect("reports the request sent by middleware", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor
.execute(request, (original, handler) =>
handler(
original.pipe(
HttpClientRequest.setUrl("https://proxy.test/v1/chat?api_key=proxy-secret"),
HttpClientRequest.setHeader("authorization", "Bearer proxy-secret"),
),
),
)
.pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: proxy disconnected <redacted>",
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
http: {
request: {
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
headers: { authorization: "<redacted>" },
},
},
})
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: input.request,
cause: systemError("ECONNRESET", "proxy disconnected proxy-secret"),
}),
}),
),
),
),
),
)
it.effect("classifies context overflow responses", () => it.effect("classifies context overflow responses", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
+6 -20
View File
@@ -1,5 +1,5 @@
import { Effect, Layer, Ref } from "effect" import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js" import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js"
import type { Service as LLMClientService } from "../../src/route/client.js" import type { Service as LLMClientService } from "../../src/route/client.js"
import type { Service as RequestExecutorService } from "../../src/route/executor.js" import type { Service as RequestExecutorService } from "../../src/route/executor.js"
@@ -14,9 +14,7 @@ export type HandlerInput = {
) => HttpClientResponse.HttpClientResponse ) => HttpClientResponse.HttpClientResponse
} }
export type Handler = ( export type Handler = (input: HandlerInput) => Effect.Effect<HttpClientResponse.HttpClientResponse>
input: HandlerInput,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>
const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> => const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
Layer.succeed( Layer.succeed(
@@ -36,12 +34,6 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
export interface SystemError extends Error {
readonly code: string
}
export const systemError = (code: string, message: string): SystemError => Object.assign(new Error(message), { code })
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => { export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer)) const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer) const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
@@ -71,20 +63,14 @@ export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(h
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to * Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
* exercise transport errors that surface during parsing. * exercise transport errors that surface during parsing.
*/ */
export const truncatedStream = (chunks: ReadonlyArray<string>, error: Error = new Error("connection reset")) => export const truncatedStream = (chunks: ReadonlyArray<string>) =>
dynamicResponse((input) => dynamicResponse((input) =>
Effect.sync(() => { Effect.sync(() => {
const encoder = new TextEncoder() const encoder = new TextEncoder()
let index = 0
const stream = new ReadableStream({ const stream = new ReadableStream({
pull(controller) { start(controller) {
const chunk = chunks[index] for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
if (chunk !== undefined) { controller.error(new Error("connection reset"))
index++
controller.enqueue(encoder.encode(chunk))
return
}
controller.error(error)
}, },
}) })
return input.respond(stream, { headers: SSE_HEADERS }) return input.respond(stream, { headers: SSE_HEADERS })
@@ -271,47 +271,6 @@ describe("Anthropic Messages route", () => {
}), }),
) )
it.effect("batches parallel tool results into one Anthropic user message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Check both cities."),
Message.assistant([
{ type: "text", text: "I'll check both." },
ToolCallPart.make({ id: "call_paris", name: "weather", input: { city: "Paris" } }),
ToolCallPart.make({ id: "call_london", name: "weather", input: { city: "London" } }),
]),
Message.tool({ id: "call_paris", name: "weather", result: { temperature: 22 } }),
Message.tool({ id: "call_london", name: "weather", result: { temperature: 18 } }),
],
cache: "none",
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: [{ type: "text", text: "Check both cities." }] },
{
role: "assistant",
content: [
{ type: "text", text: "I'll check both." },
{ type: "tool_use", id: "call_paris", name: "weather", input: { city: "Paris" } },
{ type: "tool_use", id: "call_london", name: "weather", input: { city: "London" } },
],
},
{
role: "user",
content: [
{ type: "tool_result", tool_use_id: "call_paris", content: '{"temperature":22}' },
{ type: "tool_result", tool_use_id: "call_london", content: '{"temperature":18}' },
],
},
])
expect(prepared.body.messages).toHaveLength(3)
}),
)
it.effect("keeps tools and sends tool_choice none", () => it.effect("keeps tools and sends tool_choice none", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
@@ -956,54 +915,6 @@ describe("Anthropic Messages route", () => {
}), }),
) )
it.effect("assembles and persists multiple tool calls from one Anthropic response", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "call_paris", name: "weather", input: {} },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' },
},
{ type: "content_block_stop", index: 0 },
{
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "call_london", name: "weather", input: {} },
},
{
type: "content_block_delta",
index: 1,
delta: { type: "input_json_delta", partial_json: '{"city":"London"}' },
},
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 2 } },
{ type: "message_stop" },
),
),
),
)
expect(response.toolCalls).toMatchObject([
{ id: "call_paris", name: "weather", input: { city: "Paris" } },
{ id: "call_london", name: "weather", input: { city: "London" } },
])
expect(response.message.content).toMatchObject([
{ type: "tool-call", id: "call_paris", name: "weather", input: { city: "Paris" } },
{ type: "tool-call", id: "call_london", name: "weather", input: { city: "London" } },
])
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_use" })
}),
)
it.effect("keeps malformed server tool input terminal", () => it.effect("keeps malformed server tool input terminal", () =>
Effect.gen(function* () { Effect.gen(function* () {
const body = sseEvents( const body = sseEvents(
+7 -39
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Ref, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http" import { HttpClientRequest } from "effect/unstable/http"
import { import {
HttpOptions, HttpOptions,
@@ -22,7 +22,7 @@ import { ProviderShared } from "../../src/protocols/shared.js"
import { Auth, LLMClient } from "../../src/route.js" import { Auth, LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js" import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js" import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse, systemError, truncatedStream } from "../lib/http.js" import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http.js"
import { deltaChunk, usageChunk } from "../lib/openai-chunks.js" import { deltaChunk, usageChunk } from "../lib/openai-chunks.js"
import { sseEvents } from "../lib/sse.js" import { sseEvents } from "../lib/sse.js"
@@ -1221,44 +1221,12 @@ describe("OpenAI Chat route", () => {
it.effect("surfaces transport errors that occur mid-stream", () => it.effect("surfaces transport errors that occur mid-stream", () =>
Effect.gen(function* () { Effect.gen(function* () {
const layer = truncatedStream( const layer = truncatedStream([
[`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`], `data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`,
systemError("ECONNRESET", "socket closed unexpectedly"), ])
) const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
const events = yield* Ref.make<ReadonlyArray<LLMEvent>>([])
const error = yield* LLMClient.stream(request).pipe(
Stream.tap((event) => Ref.update(events, (current) => [...current, event])),
Stream.runDrain,
Effect.provide(layer),
Effect.flip,
)
expect((yield* Ref.get(events)).some((event) => event.type === "text-delta")).toBeTrue() expect(error.message).toContain("Failed to read openai/openai-chat stream")
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed unexpectedly",
transport: "http",
operation: "read",
code: "ECONNRESET",
url: "https://api.openai.test/v1/chat/completions",
})
}),
)
it.effect("surfaces transport errors before the first stream frame", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(truncatedStream([], systemError("ECONNRESET", "socket closed before output"))),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed before output",
transport: "http",
operation: "read",
code: "ECONNRESET",
})
}), }),
) )
@@ -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, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise" import type { SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types" import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
import { expect, type Page } from "@playwright/test" import { expect, type Page } from "@playwright/test"
import { Schema } from "effect" import { Schema } from "effect"
@@ -90,8 +90,6 @@ export async function setupTimeline(
page: Page, page: Page,
input: { input: {
messages?: TimelineMessage[] messages?: TimelineMessage[]
currentMessages?: SessionMessageInfo[]
sessionStatus?: Record<string, SessionStatus>
settings?: Record<string, boolean> settings?: Record<string, boolean>
sessions?: Session[] sessions?: Session[]
cpuRate?: number cpuRate?: number
@@ -104,22 +102,13 @@ export async function setupTimeline(
} = {}, } = {},
) { ) {
const sessions = input.sessions ?? [session()] const sessions = input.sessions ?? [session()]
const messages = const messages = validateTimelineMessages([
input.currentMessages ?? ...(input.seedHistory ? historyMessages(18) : []),
validateTimelineMessages([ ...(input.messages ?? [userMessage(), assistantMessage()]),
...(input.seedHistory ? historyMessages(18) : []), ])
...(input.messages ?? [userMessage(), assistantMessage()]), const active = messages.findLast((message) => message.info.role === "assistant")
])
const active = messages.findLast((message) =>
"info" in message ? message.info.role === "assistant" : message.type === "assistant",
)
const initialStatus = decodeStatus( const initialStatus = decodeStatus(
active && active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
("info" in active
? active.info.role === "assistant" && active.info.time.completed === undefined
: active.type === "assistant" && active.time.completed === undefined)
? { type: "busy" }
: { type: "idle" },
decodeOptions, decodeOptions,
) )
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
@@ -133,7 +122,7 @@ export async function setupTimeline(
project: project(), project: project(),
provider: provider(), provider: provider(),
sessions, sessions,
sessionStatus: input.sessionStatus ?? { [sessionID]: initialStatus }, sessionStatus: { [sessionID]: initialStatus },
pageMessages: () => ({ pageMessages: () => ({
items: messages, items: messages,
}), }),
@@ -122,7 +122,6 @@ test("applies message latency after a list response gate is released", async ()
const gate = Promise.withResolvers<void>() const gate = Promise.withResolvers<void>()
let handler: ((route: Route) => Promise<void>) | undefined let handler: ((route: Route) => Promise<void>) | undefined
const page = { const page = {
addInitScript: () => Promise.resolve(),
route: (_url: string, callback: (route: Route) => Promise<void>) => { route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback handler = callback
return Promise.resolve() return Promise.resolve()
@@ -1,7 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server" import { currentSession } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
const serverA = "http://127.0.0.1:4096" const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097" const serverB = "http://127.0.0.1:4097"
@@ -79,8 +78,6 @@ function session(id: string, directory: string, title: string) {
} }
async function mockServers(page: Page, requests: string[]) { async function mockServers(page: Page, requests: string[]) {
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await page.route("**/*", async (route) => { await page.route("**/*", async (route) => {
const url = new URL(route.request().url()) const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback() if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
@@ -88,6 +85,7 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/health") return json(route, { pid: 1 }) if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} })
@@ -144,3 +142,7 @@ function json(route: Route, body: unknown, status = 200) {
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
@@ -13,8 +13,6 @@ const sessionB = session("ses_server_b", directoryB, "Server B session")
test("session settings use the remote server context", async ({ page }) => { test("session settings use the remote server context", async ({ page }) => {
const permissionRequests: string[] = [] const permissionRequests: string[] = []
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await mockServers(page, permissionRequests) await mockServers(page, permissionRequests)
await configureServers(page) await configureServers(page)
@@ -48,7 +46,6 @@ test("session settings use the remote server context", async ({ page }) => {
test("auto-accept responds for an unfocused server session", async ({ page }) => { test("auto-accept responds for an unfocused server session", async ({ page }) => {
const permissionRequests: string[] = [] const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = [] const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverB })
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
server: serverA, server: serverA,
retry: 20, retry: 20,
@@ -184,6 +181,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true) return json(route, true)
} }
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/provider") if (url.pathname === "/api/provider")
return json(route, { return json(route, {
location: { directory }, location: { directory },
@@ -327,3 +325,7 @@ function json(route: Route, body: unknown, status = 200) {
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
@@ -11,23 +11,18 @@ const title = "Request dock regression"
test("shows a pending question dock", async ({ page }) => { test("shows a pending question dock", async ({ page }) => {
await mockServer(page, { await mockServer(page, {
forms: [ questions: [
{ {
id: "frm_question_request", id: "question-request",
sessionID, sessionID,
title: "Questions", questions: [
metadata: { kind: "question" },
fields: [
{ {
key: "q0", header: "Implementation",
type: "string", question: "Which implementation should be used?",
title: "Implementation",
description: "Which implementation should be used?",
options: [ options: [
{ value: "minimal", label: "Minimal", description: "Use the smallest correct change" }, { label: "Minimal", description: "Use the smallest correct change" },
{ value: "extended", label: "Extended", description: "Include additional behavior" }, { label: "Extended", description: "Include additional behavior" },
], ],
custom: true,
}, },
], ],
}, },
@@ -47,7 +42,7 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = [] const rejectRequests: string[] = []
page.on("request", (request) => { page.on("request", (request) => {
if (request.method() !== "POST") return if (request.method() !== "POST") return
if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`) if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
rejectRequests.push(request.url()) rejectRequests.push(request.url())
}) })
@@ -72,10 +67,10 @@ test("shows a pending question dock", async ({ page }) => {
const reply = page.waitForRequest( const reply = page.waitForRequest(
(request) => (request) =>
request.method() === "POST" && request.method() === "POST" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/reply`, new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
) )
await question.getByRole("button", { name: "Submit" }).click() await question.getByRole("button", { name: "Submit" }).click()
expect((await reply).postDataJSON()).toEqual({ answer: { q0: "minimal" } }) expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
}) })
test("shows a pending permission dock", async ({ page }) => { test("shows a pending permission dock", async ({ page }) => {
@@ -114,7 +109,7 @@ test("restores the draft caret before typing after a request dock closes", async
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
retry: 20, retry: 20,
}) })
await mockServer(page, { forms: [] }) await mockServer(page, { questions: [] })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection() await transport.waitForConnection()
await expectSessionTitle(page, title) await expectSessionTitle(page, title)
@@ -140,24 +135,18 @@ test("restores the draft caret before typing after a request dock closes", async
await transport.send({ await transport.send({
directory, directory,
payload: { payload: {
type: "form.created", type: "question.asked",
properties: { properties: {
form: { id: "question-caret",
id: "frm_question_caret", sessionID,
sessionID, questions: [
title: "Questions", {
metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } }, header: "Continue",
fields: [ question: "Continue?",
{ options: [{ label: "Yes", description: "Continue the session" }],
key: "q0", },
type: "string", ],
title: "Continue", tool: { messageID: "message-caret", callID: "call-caret" },
description: "Continue?",
options: [{ value: "yes", label: "Yes", description: "Continue the session" }],
custom: true,
},
],
},
}, },
}, },
}) })
@@ -167,10 +156,7 @@ test("restores the draft caret before typing after a request dock closes", async
await transport.send({ await transport.send({
directory, directory,
payload: { payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
type: "form.cancelled",
properties: { sessionID, id: "frm_question_caret" },
},
}) })
await expect(question).toHaveCount(0) await expect(question).toHaveCount(0)
await expect(editor).toBeVisible() await expect(editor).toBeVisible()
@@ -184,8 +170,6 @@ async function mockServer(
requests: { requests: {
permissions?: unknown[] | (() => unknown[]) permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[])
forms?: unknown[] | (() => unknown[])
sessionStatus?: Record<string, unknown>
}, },
) { ) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
@@ -230,8 +214,6 @@ async function mockServer(
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
permissions: requests.permissions, permissions: requests.permissions,
questions: requests.questions, questions: requests.questions,
forms: requests.forms,
sessionStatus: requests.sessionStatus,
}) })
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
@@ -1,184 +0,0 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
const assistant = (completed: boolean, tool = false, childID?: string) =>
({
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: tool
? [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
time: { created: 2 },
},
]
: [{ type: "text", text: "Working" }],
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
}) satisfies SessionMessageInfo
test("renders current protocol notices in CLI order", async ({ page }) => {
const ownerWarnings: string[] = []
page.on("console", (message) => {
if (message.text().includes("computations created outside a `createRoot` or `render`"))
ownerWarnings.push(message.text())
})
await setupTimeline(page, {
currentMessages: [
user,
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
assistant(true),
{
id: "msg_subagent",
type: "synthetic",
text: "done",
description: "Search code",
metadata: { source: "subagent", agent: "explore", state: "completed" },
time: { created: 4 },
},
{
id: "msg_restart",
type: "synthetic",
text: "continue",
description: "Continuing after restart",
time: { created: 5 },
},
{ id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 6 } },
],
})
const notices = page.locator('[data-slot="session-timeline-notice"]')
await expect(notices).toHaveCount(4)
await expect(notices.nth(0)).toContainText("Agent · explore")
await expect(notices.nth(1)).toContainText("explore finished · Search code")
await expect(notices.nth(2)).toContainText("Continuing after restart")
await expect(notices.nth(3)).toContainText("Skill · Review")
expect(ownerWarnings).toEqual([])
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, { currentMessages: [user, assistant(false, true)] })
await expect(page.locator('[data-component="task-tool-card"]')).toBeVisible()
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
)
await page.keyboard.press("Control+b")
await request
})
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
const childID = "ses_running_child"
await setupTimeline(page, {
currentMessages: [user, assistant(false, true, childID)],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
})
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
await page.locator('[data-component="task-tool-card"]').click()
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
await expect(page.locator('[data-component="session-background-dock"]')).toHaveCount(0)
})
test("shows a badge for active background work", async ({ page }) => {
const childID = "ses_background_child"
await setupTimeline(page, {
currentMessages: [user, assistant(true)],
sessions: [session(), session({ id: childID, parentID: sessionID })],
sessionStatus: { [childID]: { type: "busy" } },
})
await expect(page.locator('[data-component="session-background-dock"]')).toContainText("1 subagent in background")
})
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
const backgroundID = "ses_background_existing"
const blockingID = "ses_background_blocking"
await setupTimeline(page, {
currentMessages: [
user,
{
id: "msg_backgrounded",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_backgrounded",
name: "subagent",
state: {
status: "completed",
input: { description: "Background task" },
content: [{ type: "text", text: "working" }],
metadata: { sessionID: backgroundID, status: "running" },
},
time: { created: 2, completed: 3 },
},
{
type: "tool",
id: "call_shell_backgrounded",
name: "shell",
state: {
status: "completed",
input: { command: "sleep 120" },
content: [{ type: "text", text: "working" }],
metadata: { shellID: "shell_backgrounded", status: "running" },
},
time: { created: 2, completed: 3 },
},
],
time: { created: 2, completed: 3 },
},
{
id: "msg_blocking",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_blocking",
name: "subagent",
state: {
status: "running",
input: { description: "Foreground task" },
metadata: { sessionID: blockingID },
},
time: { created: 4 },
},
],
time: { created: 4 },
},
],
sessions: [
session(),
session({ id: backgroundID, parentID: sessionID, title: "Background task" }),
session({ id: blockingID, parentID: sessionID, title: "Foreground task" }),
],
sessionStatus: {
[sessionID]: { type: "busy" },
[backgroundID]: { type: "busy" },
[blockingID]: { type: "busy" },
},
})
const dock = page.locator('[data-component="session-background-dock"]')
await expect(dock).toContainText("Move 1 subagent to background")
await expect(dock.getByText("Running 1 shell and 1 subagent in background", { exact: true })).toBeVisible()
await expect(
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
).toHaveAttribute("data-active", "true")
})
@@ -189,12 +189,7 @@ test.describe("smoke: session timeline", () => {
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]') .querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect() ?.getBoundingClientRect()
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom }) samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
if ( if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
!firstPaint &&
visible.includes(last) &&
Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1 &&
!root.querySelector('[data-markdown-key="initial"]')
) {
firstPaint = true firstPaint = true
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => { root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
const rect = row.getBoundingClientRect() const rect = row.getBoundingClientRect()
@@ -209,16 +204,10 @@ test.describe("smoke: session timeline", () => {
} }
;( ;(
window as Window & { window as Window & {
__sessionTabPaint?: { __sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
samples: typeof samples
painted: () => boolean
removed: () => number
stop: () => void
}
} }
).__sessionTabPaint = { ).__sessionTabPaint = {
samples, samples,
painted: () => firstPaint,
removed: () => removedFirstPaintNodes, removed: () => removedFirstPaintNodes,
stop: () => { stop: () => {
running = false running = false
@@ -230,19 +219,17 @@ test.describe("smoke: session timeline", () => {
) )
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
await page.waitForFunction(() => { await page.waitForFunction(() =>
const probe = ( (
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }>; painted: () => boolean } } window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
).__sessionTabPaint ).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
return probe?.painted() && probe.samples.some((sample) => sample.ids.length > 0) )
})
await page.waitForTimeout(200) await page.waitForTimeout(200)
const first = await page.evaluate(() => { const first = await page.evaluate(() => {
const probe = ( const probe = (
window as Window & { window as Window & {
__sessionTabPaint?: { __sessionTabPaint?: {
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
painted: () => boolean
removed: () => number removed: () => number
stop: () => void stop: () => void
} }
-112
View File
@@ -32,110 +32,15 @@ export interface MockServerConfig {
todos?: (sessionID: string) => unknown[] todos?: (sessionID: string) => unknown[]
permissions?: unknown[] | (() => unknown[]) permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[])
forms?: unknown[] | (() => unknown[])
fileList?: (path: string) => unknown | Promise<unknown> fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown> fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>) sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
} }
type MockStreamWindow = Window & {
__testSseTransport?: unknown
__mockServerStream?: { push: (payloads: unknown[]) => void }
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const cursors = new Map<string, string>() const cursors = new Map<string, string>()
let nextCursor = 0 let nextCursor = 0
await page.addInitScript(
({ port, retry }) => {
const host = window as MockStreamWindow
if (host.__testSseTransport || host.__mockServerStream) return
const originalFetch = window.fetch.bind(window)
const encoder = new TextEncoder()
const state: {
controller?: ReadableStreamDefaultController<Uint8Array>
buffer: string[]
connections: number
} = { buffer: [], connections: 0 }
const frame = (payload: unknown) => `data: ${JSON.stringify(payload)}\n\n`
host.__mockServerStream = {
push(payloads: unknown[]) {
const frames = payloads.map(frame)
const controller = state.controller
if (!controller) {
state.buffer.push(...frames)
return
}
frames.forEach((item) => controller.enqueue(encoder.encode(item)))
},
}
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const url = new URL(request.url)
if (url.port !== port || url.pathname !== "/api/event") return originalFetch(request)
state.connections += 1
const id = state.connections
let ended = false
let own: ReadableStreamDefaultController<Uint8Array> | undefined
const stream = new ReadableStream<Uint8Array>({
start(controller) {
own = controller
state.controller = controller
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
controller.enqueue(
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
)
state.buffer.splice(0).forEach((item) => controller.enqueue(encoder.encode(item)))
request.signal.addEventListener(
"abort",
() => {
if (ended) return
ended = true
if (state.controller === controller) state.controller = undefined
controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
},
{ once: true },
)
},
cancel() {
if (ended) return
ended = true
if (state.controller === own) state.controller = undefined
},
})
return Promise.resolve(
new Response(stream, {
status: 200,
headers: { "cache-control": "no-cache", "content-type": "text/event-stream" },
}),
)
}
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
},
{ port: process.env.PLAYWRIGHT_SERVER_PORT ?? "4096", retry: config.eventRetry },
)
if (config.events) {
const pump = { busy: false }
const timer = setInterval(() => {
if (pump.busy) return
const batch = config.events?.() ?? []
if (batch.length === 0) return
pump.busy = true
void page
.evaluate(
(payloads) => (window as MockStreamWindow).__mockServerStream?.push(payloads),
batch.map(currentEvent),
)
.catch(() => {})
.finally(() => {
pump.busy = false
})
}, 50)
page.on("close", () => clearInterval(timer))
}
const staticRoutes: Record<string, unknown> = { const staticRoutes: Record<string, unknown> = {
"/path": { "/path": {
state: config.directory, state: config.directory,
@@ -290,11 +195,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
location: location(config), location: location(config),
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []), data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
}) })
if (path === "/api/form/request")
return json(route, {
location: location(config),
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
})
if (path === "/api/vcs") if (path === "/api/vcs")
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } }) return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] }) if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
@@ -380,18 +280,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
} }
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
if (sessionForm && route.request().method() === "GET") {
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
return json(route, { data: forms.filter((form) => (form as { sessionID?: string }).sessionID === sessionForm) })
}
if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
return json(route, { data: [] })
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
} }
+22 -23
View File
@@ -54,7 +54,7 @@ import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" import { TargetSessionRouteContent } from "@/pages/session"
import { Home } from "@/pages/home" import { Home } from "@/pages/home"
const NewSession = lazy(() => import("@/pages/new-session")) const NewSession = lazy(() => import("@/pages/new-session"))
@@ -86,23 +86,18 @@ function TargetServerRoute(props: ParentProps) {
return ( return (
// Owns the server-identity remount. Session changes must not remount this subtree. // Owns the server-identity remount. Session changes must not remount this subtree.
<Show when={requireServerKey(params.serverKey)} keyed> <Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn()}> <ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn()}>{props.children}</ServerSyncProvider> <ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
</ServerSDKProvider> </ServerSDKProvider>
</Show> </Show>
) )
} }
function TargetSessionRoute() { const TargetSessionRoute = () => (
const params = useParams<{ serverKey: string; id: string }>() <TargetServerRoute>
return ( <TargetSessionRouteContent />
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded> </TargetServerRoute>
<TargetServerRoute> )
<TargetSessionRouteContent />
</TargetServerRoute>
</SessionRouteErrorBoundary>
)
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell for that server. // server via ServerKey, then provide the server-scoped shell for that server.
@@ -135,14 +130,16 @@ function DraftRoute() {
function ResolvedDraftRoute(props: { draft: DraftTab }) { function ResolvedDraftRoute(props: { draft: DraftTab }) {
const global = useGlobal() const global = useGlobal()
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server)) const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
const directory = () => props.draft.directory
const serverKey = () => props.draft.server
return ( return (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed> <Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<ServerSDKProvider server={conn()}> <ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn()}> <ServerSyncProvider server={conn}>
<ModelsProvider directory={props.draft.directory}> <ModelsProvider directory={directory}>
<SDKProvider directory={props.draft.directory}> <SDKProvider directory={directory}>
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}> <DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders> <DraftProviders>
<NewSession /> <NewSession />
</DraftProviders> </DraftProviders>
@@ -240,7 +237,7 @@ function DesktopCommands() {
} }
type ServerScopedShellProps = ParentProps<{ type ServerScopedShellProps = ParentProps<{
directory?: string directory?: () => string | undefined
serverScoped?: JSX.Element serverScoped?: JSX.Element
}> }>
@@ -453,10 +450,12 @@ export function AppInterface(props: {
// route changes. Draft and session routes override only their server-bound data // route changes. Draft and session routes override only their server-bound data
// providers beneath it. // providers beneath it.
const ServerShell = (shellProps: ParentProps) => ( const ServerShell = (shellProps: ParentProps) => (
<SharedProviders> <QueryProvider>
{props.children} <SharedProviders>
{shellProps.children} {props.children}
</SharedProviders> {shellProps.children}
</SharedProviders>
</QueryProvider>
) )
return ( return (
+3 -3
View File
@@ -1,5 +1,5 @@
import { useIsRouting, useLocation } from "@solidjs/router" import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount, Show } from "solid-js" import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
@@ -571,7 +571,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={language.t(`debugBar.direction.${language.direction()}`)} value={language.t(`debugBar.direction.${language.direction()}`)}
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")} onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
/> />
<Show when={platform.setForceFocus}> {platform.setForceFocus && (
<ToggleCell <ToggleCell
active={state.focus} active={state.focus}
inline={props.inline} inline={props.inline}
@@ -580,7 +580,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")} value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
onClick={() => void toggleFocus()} onClick={() => void toggleFocus()}
/> />
</Show> )}
</div> </div>
</aside> </aside>
) )
@@ -12,7 +12,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js" import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/components/external-link" import { ExternalLink } from "@/components/external-link"
@@ -20,7 +20,6 @@ import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { useIntegrations } from "@/hooks/use-integrations"
import { CustomProviderForm } from "./dialog-custom-provider" import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller" import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller"
@@ -41,7 +40,7 @@ export function useProviderConnectController(options: { onBack?: () => void } =
} }
export const DialogConnectProvider: Component<{ export const DialogConnectProvider: Component<{
directory?: string directory?: Accessor<string | undefined>
controller?: ReturnType<typeof useProviderConnectController> controller?: ReturnType<typeof useProviderConnectController>
}> = (props) => { }> = (props) => {
const fallback = useProviderConnectController() const fallback = useProviderConnectController()
@@ -137,11 +136,15 @@ export const DialogConnectProvider: Component<{
) )
} }
function ProviderPicker(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) { function ProviderPicker(props: {
directory?: Accessor<string | undefined>
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const settings = useSettings() const settings = useSettings()
if (settings.general.newLayoutDesigns()) if (settings.general.newLayoutDesigns())
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} /> return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
const integrations = useIntegrations(() => props.directory) const providers = useProviders(() => props.directory?.())
const language = useLanguage() const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular") const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other") const otherGroup = () => language.t("dialog.provider.group.other")
@@ -163,7 +166,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
key={(x) => x?.id} key={(x) => x?.id}
items={() => { items={() => {
language.locale() language.locale()
return [{ id: CUSTOM_ID, name: customLabel() }, ...integrations.list()] return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
}} }}
filterKeys={["id", "name"]} filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
@@ -208,8 +211,12 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
) )
} }
function ProviderPickerV2(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) { function ProviderPickerV2(props: {
const integrations = useIntegrations(() => props.directory) directory?: Accessor<string | undefined>
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const providers = useProviders(() => props.directory?.())
const language = useLanguage() const language = useLanguage()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
filter: "", filter: "",
@@ -221,7 +228,7 @@ function ProviderPickerV2(props: { directory?: string; onSelect: (provider: stri
const all = createMemo(() => { const all = createMemo(() => {
language.locale() language.locale()
const query = store.filter.trim().toLowerCase() const query = store.filter.trim().toLowerCase()
const values = [custom(), ...integrations.list()] const values = [custom(), ...providers.all().values()]
if (!query) return values if (!query) return values
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query)) return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
}) })
@@ -357,7 +364,7 @@ function ProviderPickerV2(props: { directory?: string; onSelect: (provider: stri
function ProviderConnection(props: { function ProviderConnection(props: {
provider: string provider: string
directory?: string directory?: Accessor<string | undefined>
onBack: () => void onBack: () => void
setBack: (handler: () => void) => void setBack: (handler: () => void) => void
}) { }) {
@@ -367,9 +374,12 @@ function ProviderConnection(props: {
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(() => props.directory) const providers = useProviders(() => props.directory?.())
const directory = () => props.directory ?? decode64(params.dir) const directory = () => props.directory?.() ?? decode64(params.dir)
const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
)
const controller = createProviderConnectionController({ const controller = createProviderConnectionController({
provider: () => props.provider, provider: () => props.provider,
directory, directory,
@@ -383,14 +393,6 @@ function ProviderConnection(props: {
}) })
}, },
}) })
const provider = createMemo(() => ({
id: props.provider,
name:
providers.all().get(props.provider)?.name ??
serverSync().data.provider.all.get(props.provider)?.name ??
controller.integration()?.name ??
props.provider,
}))
const methodLabel = (value?: { type?: string; label?: string }) => { const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return "" if (!value) return ""
if (value.type === "key") return language.t("provider.connect.method.apiKey") if (value.type === "key") return language.t("provider.connect.method.apiKey")
@@ -1,238 +0,0 @@
.project-settings-v2-dialog [data-slot="dialog-container"] {
background: var(--v2-background-bg-base);
}
.project-settings-v2-dialog [data-slot="dialog-body"] {
padding: 0;
overflow: hidden;
}
.project-settings-v2 {
height: 100%;
}
.project-settings-v2-nav {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.project-settings-v2-panel {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
overflow: hidden !important;
user-select: none;
}
.project-settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
user-select: text;
}
.project-settings-v2-form {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.project-settings-v2-scroll {
display: flex;
flex: 1;
flex-direction: column;
gap: 24px;
min-height: 0;
padding: 40px;
overflow-y: auto;
}
.project-settings-page-header {
display: flex;
flex-direction: column;
gap: 6px;
}
.project-settings-page-header h2 {
color: var(--v2-text-text-base);
font-size: 15px;
font-weight: 640;
line-height: 1;
}
.project-settings-page-header > span {
color: var(--v2-text-text-muted);
font-size: 11px;
font-weight: 440;
line-height: 1;
}
.project-settings-extensions {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 40px;
}
.project-settings-extension-tabs {
flex: 1;
height: auto;
min-height: 0;
margin-top: 24px;
overflow: visible;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: auto;
padding-inline: 0 !important;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
display: none;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger-wrapper"] {
border-radius: 6px;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger"] {
padding-inline: 8px;
}
.project-settings-extension-tabs [data-slot="tabs-v2-content"] {
overflow-y: auto;
}
.project-settings-extension-section {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 20px;
}
.project-settings-extension-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: 1;
}
.project-settings-extension-section-header > :last-child {
color: var(--v2-text-text-faint);
font-size: 11px;
font-weight: 440;
}
.project-settings-extension-link {
color: var(--v2-text-text-accent) !important;
text-decoration: none;
}
.project-settings-extension-link:hover {
text-decoration: underline;
}
.project-settings-extension-card {
padding-inline: 12px;
overflow: hidden;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 8px;
background: var(--v2-background-bg-base);
}
.project-settings-extension-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 48px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.project-settings-extension-row:last-child {
border-bottom: 0;
}
.project-settings-extension-row-main {
display: flex;
flex: 1;
align-items: center;
gap: 10px;
min-width: 0;
}
.project-settings-extension-row-icon {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.project-settings-extension-row-name {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-settings-extension-row-status {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-size: 11px;
}
.project-settings-extension-row-status-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--v2-state-fg-warning);
}
.project-settings-shared {
display: flex;
flex-direction: column;
gap: 12px;
}
.project-settings-shared-trigger {
display: flex;
align-items: center;
align-self: flex-start;
gap: 6px;
color: var(--v2-text-text-base);
font-size: 11px;
font-weight: 530;
}
.project-settings-shared-chevron {
width: 12px;
height: 12px;
color: var(--v2-icon-icon-muted);
transition: transform 120ms ease;
}
.project-settings-shared-chevron.open {
transform: rotate(90deg);
}
.project-settings-shared-count {
padding: 1px 4px;
border-radius: 3px;
background: var(--v2-background-bg-layer-02);
color: var(--v2-text-text-muted);
font-size: 9px;
}
@@ -1,223 +1,156 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2" import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Field } from "@opencode-ai/ui/v2/field-v2" import { Field } from "@opencode-ai/ui/v2/field-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { Icon } from "@opencode-ai/ui/icon"
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2" import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2" import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { For, Show, createSignal, startTransition } from "solid-js" import { For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout" import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
import { SDKProvider } from "@/context/sdk"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { createEditProjectModel } from "./edit-project" import { createEditProjectModel } from "./edit-project"
import { ProjectSettingsExtensions } from "./project-settings-extensions"
import { SettingsServerDataScope } from "./settings-server-picker"
import "./settings-v2/settings-v2.css"
import "./dialog-edit-project-v2.css"
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) { export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
return (
<SettingsServerDataScope server={props.server}>
<SDKProvider directory={props.project.worktree}>
<ProjectSettingsDialog project={props.project} server={props.server} />
</SDKProvider>
</SettingsServerDataScope>
)
}
function ProjectSettingsDialog(props: { project: LocalProject; server: ServerConnection.Any }) {
const language = useLanguage() const language = useLanguage()
const model = createEditProjectModel(props) const model = createEditProjectModel(props)
const projectName = () => displayName(props.project)
const [tab, setTab] = createSignal("general")
const Footer = () => (
<DialogFooter>
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</ButtonV2>
</DialogFooter>
)
return ( return (
<Dialog size="x-large" variant="settings" class="project-settings-v2-dialog"> <Dialog fit>
<TabsV2 <form onSubmit={model.submit} class="contents">
orientation="vertical" <DialogHeader>
variant="settings" <DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
value={tab()} </DialogHeader>
onChange={(value) => void startTransition(() => setTab(value))} <DividerV2 />
class="project-settings-v2" <DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
> <Field>
<TabsV2.List> <Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
<div class="project-settings-v2-nav"> <TextInputV2
<TabsV2.Trigger value="general"> autofocus
<ProjectAvatar appearance="large"
fallback={projectName()} class="!w-full"
variant={getProjectAvatarVariant(props.project.icon?.color)} value={model.store.name}
class="!size-4 shrink-0" placeholder={model.folderName()}
/> onInput={(event) => model.setStore("name", event.currentTarget.value)}
<span class="truncate">{projectName()}</span> />
</TabsV2.Trigger> </Field>
<TabsV2.Trigger value="scripts">
<Icon name="code" size="small" />
{language.t("project.settings.scripts")}
</TabsV2.Trigger>
<TabsV2.Trigger value="extensions">
<Icon name="extensions" size="small" />
{language.t("settings.tab.extensions")}
</TabsV2.Trigger>
</div>
</TabsV2.List>
<TabsV2.Content value="general" class="project-settings-v2-panel"> <div class="flex w-full flex-col gap-2">
<form onSubmit={model.submit} class="project-settings-v2-form"> <div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
<div class="project-settings-v2-scroll"> {language.t("dialog.project.edit.icon")}
<div class="project-settings-page-header"> </div>
<h2>{language.t("dialog.project.edit.title")}</h2> <div class="flex items-center gap-3">
<span>{language.t("project.settings.general.description")}</span> <button
</div> type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
<Field> class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label> classList={{
<TextInputV2 "bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
autofocus }}
appearance="large" onMouseEnter={() => model.setStore("iconHover", true)}
class="!w-full" onMouseLeave={() => model.setStore("iconHover", false)}
value={model.store.name} onDrop={model.drop}
placeholder={model.folderName()} onDragOver={model.dragOver}
onInput={(event) => model.setStore("name", event.currentTarget.value)} onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
src={getProjectAvatarSource(props.project.id, {
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
})}
variant={getProjectAvatarVariant(model.store.color)}
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
/> />
</Field> <span
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
classList={{
"opacity-100": model.store.iconHover,
"opacity-0": !model.store.iconHover,
}}
>
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
</span>
</button>
<input
ref={(element) => {
model.setIconInput(element)
}}
type="file"
accept="image/*"
class="hidden"
onChange={model.inputChange}
/>
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
</div>
<div class="flex w-full flex-col gap-2"> <Show when={!model.store.iconOverride}>
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base"> <div class="flex w-full flex-col gap-2">
{language.t("dialog.project.edit.icon")} <div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
</div> {language.t("dialog.project.edit.color")}
<div class="flex items-center gap-3"> </div>
<button <div class="-ml-1 flex gap-1.5">
type="button" <For each={PROJECT_AVATAR_VARIANTS}>
aria-label={language.t("dialog.project.edit.icon.alt")} {(color) => (
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus" <button
classList={{ type="button"
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver, aria-label={language.t("dialog.project.edit.color.select", { color })}
}} aria-pressed={getProjectAvatarVariant(model.store.color) === color}
onMouseEnter={() => model.setStore("iconHover", true)} class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
onMouseLeave={() => model.setStore("iconHover", false)}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
src={getProjectAvatarSource(props.project.id, {
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
})}
variant={getProjectAvatarVariant(model.store.color)}
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
/>
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
classList={{ classList={{
"opacity-100": model.store.iconHover, "bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
"opacity-0": !model.store.iconHover, getProjectAvatarVariant(model.store.color) === color,
}}
onClick={() => {
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
model.setStore(
"color",
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
)
}} }}
> >
<IconV2 name={model.store.iconOverride ? "close" : "share"} /> <ProjectAvatar
</span> fallback={model.store.name || model.defaultName()}
</button> variant={getProjectAvatarVariant(color)}
<input class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
ref={(element) => model.setIconInput(element)} />
type="file" </button>
accept="image/*" )}
class="hidden" </For>
onChange={model.inputChange}
/>
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
</div> </div>
<Show when={!model.store.iconOverride}>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.color")}
</div>
<div class="-ml-1 flex gap-1.5">
<For each={PROJECT_AVATAR_VARIANTS}>
{(color) => (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
getProjectAvatarVariant(model.store.color) === color,
}}
onClick={() => {
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
model.setStore(
"color",
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
)
}}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
variant={getProjectAvatarVariant(color)}
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
</button>
)}
</For>
</div>
</div>
</Show>
</div> </div>
<Footer /> </Show>
</form>
</TabsV2.Content>
<TabsV2.Content value="scripts" class="project-settings-v2-panel"> <Field>
<form onSubmit={model.submit} class="project-settings-v2-form"> <Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
<div class="project-settings-v2-scroll"> <Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
<div class="project-settings-page-header"> <TextareaV2
<h2>{language.t("project.settings.scripts")}</h2> class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
<span>{language.t("project.settings.scripts.description")}</span> rows={3}
</div> value={model.store.startup}
<Field> placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label> spellcheck={false}
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix> onInput={(event) => model.setStore("startup", event.currentTarget.value)}
<TextareaV2 />
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono" </Field>
rows={5} </DialogBody>
value={model.store.startup} <DialogFooter>
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")} <ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
spellcheck={false} {language.t("common.cancel")}
onInput={(event) => model.setStore("startup", event.currentTarget.value)} </ButtonV2>
/> <ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
</Field> {model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</div> </ButtonV2>
<Footer /> </DialogFooter>
</form> </form>
</TabsV2.Content>
<TabsV2.Content value="extensions" class="project-settings-v2-panel">
<ProjectSettingsExtensions />
</TabsV2.Content>
</TabsV2>
</Dialog> </Dialog>
) )
} }
@@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
const directory = () => decode64(local.slug()) const directory = () => decode64(local.slug())
const handleConnectProvider = () => { const handleConnectProvider = () => {
void dialog.show(() => <DialogConnectProvider directory={directory()} />) void dialog.show(() => <DialogConnectProvider directory={directory} />)
} }
const providerRank = (id: string) => popularProviders.indexOf(id) const providerRank = (id: string) => popularProviders.indexOf(id)
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
@@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => {
const directory = () => decode64(local.slug()) const directory = () => decode64(local.slug())
const handleConnectProvider = () => { const handleConnectProvider = () => {
void dialog.show(() => <DialogConnectProvider directory={directory()} />) void dialog.show(() => <DialogConnectProvider directory={directory} />)
} }
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
const providerVisible = (providerID: string) => const providerVisible = (providerID: string) =>
@@ -1,4 +1,4 @@
import { createSignal, Index, Show } from "solid-js" import { createSignal } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -83,71 +83,61 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
{/* Bottom section - buttons and indicators (fixed position) */} {/* Bottom section - buttons and indicators (fixed position) */}
<div class="flex flex-col gap-12"> <div class="flex flex-col gap-12">
<div class="flex flex-col items-start gap-3"> <div class="flex flex-col items-start gap-3">
<Show {isLast() ? (
when={isLast()}
fallback={
<Button variant="secondary" size="large" onClick={handleNext}>
{language.t("dialog.releaseNotes.action.next")}
</Button>
}
>
<Button variant="primary" size="large" onClick={handleClose}> <Button variant="primary" size="large" onClick={handleClose}>
{language.t("dialog.releaseNotes.action.getStarted")} {language.t("dialog.releaseNotes.action.getStarted")}
</Button> </Button>
</Show> ) : (
<Button variant="secondary" size="large" onClick={handleNext}>
{language.t("dialog.releaseNotes.action.next")}
</Button>
)}
<Button variant="ghost" size="small" onClick={handleDisable}> <Button variant="ghost" size="small" onClick={handleDisable}>
{language.t("dialog.releaseNotes.action.hideFuture")} {language.t("dialog.releaseNotes.action.hideFuture")}
</Button> </Button>
</div> </div>
<Show when={paged()}> {paged() && (
<div class="flex items-center gap-1.5 -my-2.5"> <div class="flex items-center gap-1.5 -my-2.5">
<Index each={props.highlights}> {props.highlights.map((_, i) => (
{(_, i) => ( <button
<button type="button"
type="button" class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200" classList={{
"w-8": i === index(),
"w-3": i !== index(),
}}
onClick={() => setIndex(i)}
>
<div
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
classList={{ classList={{
"w-8": i === index(), "bg-icon-strong-base": i === index(),
"w-3": i !== index(), "bg-icon-weak-base": i !== index(),
}} }}
onClick={() => setIndex(i)} />
> </button>
<div ))}
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
classList={{
"bg-icon-strong-base": i === index(),
"bg-icon-weak-base": i !== index(),
}}
/>
</button>
)}
</Index>
</div> </div>
</Show> )}
</div> </div>
</div> </div>
{/* Right side - Media content (edge to edge) */} {/* Right side - Media content (edge to edge) */}
<Show when={feature()?.media}> {feature()?.media && (
{(media) => ( <div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl"> {feature()!.media!.type === "image" ? (
<Show <img
when={media().type === "image"} src={feature()!.media!.src}
fallback={ alt={feature()!.media!.alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
<video src={media().src} autoplay loop muted playsinline class="w-full h-full object-cover" /> class="w-full h-full object-cover"
} />
> ) : (
<img <video src={feature()!.media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
src={media().src} )}
alt={media().alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")} </div>
class="w-full h-full object-cover" )}
/>
</Show>
</div>
)}
</Show>
</div> </div>
</Dialog> </Dialog>
) )
@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme } from "@opencode-ai/ui/theme" import { useTheme } from "@opencode-ai/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js" import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { useIntegrations } from "@/hooks/use-integrations" import { useProviders } from "@/hooks/use-providers"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ModelTooltip } from "./model-tooltip" import { ModelTooltip } from "./model-tooltip"
@@ -22,7 +22,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
const dialog = useDialog() const dialog = useDialog()
const theme = useTheme() const theme = useTheme()
const directory = () => decode64(local.slug()) const directory = () => decode64(local.slug())
const integrations = useIntegrations(directory) const providers = useProviders(directory)
const language = useLanguage() const language = useLanguage()
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}` const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
const currentKey = createMemo(() => { const currentKey = createMemo(() => {
@@ -37,7 +37,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
void import("./dialog-connect-provider").then((x) => { void import("./dialog-connect-provider").then((x) => {
const controller = x.useProviderConnectController() const controller = x.useProviderConnectController()
controller.select(provider) controller.select(provider)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />) void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
}) })
} }
@@ -127,8 +127,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
</div> </div>
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2"> <div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
<For <For
each={integrations each={[...providers.popular()]
.list()
.filter((provider) => featuredProviders.includes(provider.id)) .filter((provider) => featuredProviders.includes(provider.id))
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))} .sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
> >
@@ -26,7 +26,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
void import("./dialog-connect-provider").then((x) => { void import("./dialog-connect-provider").then((x) => {
const controller = x.useProviderConnectController() const controller = x.useProviderConnectController()
controller.select(provider) controller.select(provider)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />) void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
}) })
} }
@@ -148,7 +148,7 @@ export function ModelSelectorPopover(props: {
const handleConnectProvider = () => { const handleConnectProvider = () => {
close("provider") close("provider")
void import("./dialog-connect-provider").then((x) => { void import("./dialog-connect-provider").then((x) => {
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />) void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
}) })
} }
const language = useLanguage() const language = useLanguage()
@@ -240,7 +240,7 @@ export function ModelSelectorPopoverV2(props: {
trigger={props.trigger} trigger={props.trigger}
models={controller.models} models={controller.models}
groups={controller.groups} groups={controller.groups}
current={controller.current()} current={controller.current}
select={controller.select} select={controller.select}
onManage={() => { onManage={() => {
void import("./dialog-manage-models").then((module) => { void import("./dialog-manage-models").then((module) => {
@@ -295,7 +295,7 @@ function ModelSelectorPopoverV2View(props: {
trigger: ModelSelectorTrigger trigger: ModelSelectorTrigger
models: (search: string) => ModelItem[] models: (search: string) => ModelItem[]
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[] groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
current: string | undefined current: () => string | undefined
select: (item: ModelItem) => void select: (item: ModelItem) => void
onManage: () => void onManage: () => void
onClose: () => void onClose: () => void
@@ -310,7 +310,7 @@ function ModelSelectorPopoverV2View(props: {
const groups = createMemo(() => props.groups(models())) const groups = createMemo(() => props.groups(models()))
const keys = () => [...models().map(modelKey), manageKey] const keys = () => [...models().map(modelKey), manageKey]
const initialActive = () => { const initialActive = () => {
const selected = props.current const selected = props.current()
const options = keys() const options = keys()
if (selected && options.includes(selected)) return selected if (selected && options.includes(selected)) return selected
return options[0] ?? "" return options[0] ?? ""
@@ -453,7 +453,7 @@ function ModelSelectorPopoverV2View(props: {
<MenuV2.GroupLabel class="gap-2 px-3"> <MenuV2.GroupLabel class="gap-2 px-3">
<span class="min-w-0 truncate">{group.items[0].provider.name}</span> <span class="min-w-0 truncate">{group.items[0].provider.name}</span>
</MenuV2.GroupLabel> </MenuV2.GroupLabel>
<MenuV2.RadioGroup value={props.current}> <MenuV2.RadioGroup value={props.current()}>
<For each={group.items}> <For each={group.items}>
{(item) => ( {(item) => (
<TooltipV2 <TooltipV2
@@ -473,7 +473,7 @@ function ModelSelectorPopoverV2View(props: {
<MenuV2.RadioItem <MenuV2.RadioItem
value={modelKey(item)} value={modelKey(item)}
data-option-key={modelKey(item)} data-option-key={modelKey(item)}
data-selected-model={props.current === modelKey(item) ? true : undefined} data-selected-model={props.current() === modelKey(item) ? true : undefined}
class="scroll-my-6 w-full" class="scroll-my-6 w-full"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }} classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => { onMouseEnter={() => {
@@ -529,7 +529,7 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
const provider = () => { const provider = () => {
void import("./dialog-connect-provider").then((x) => { void import("./dialog-connect-provider").then((x) => {
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />) void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
}) })
} }
@@ -1,238 +0,0 @@
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
import { useLanguage } from "@/context/language"
import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useSync } from "@/context/sync"
import { ExternalLink } from "./external-link"
type SkillItem = {
name: string
location: string
}
const pluginName = (item: string | [string, Record<string, unknown>]) => (typeof item === "string" ? item : item[0])
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
const ExtensionCard: Component<{ children: unknown }> = (props) => (
<div class="project-settings-extension-card">{props.children as any}</div>
)
const ExtensionRow: Component<{
icon: "mcp" | "cube" | "post-skill" | "code"
name: string
status?: string
children?: unknown
}> = (props) => (
<div class="project-settings-extension-row">
<div class="project-settings-extension-row-main">
<Icon name={props.icon} class="project-settings-extension-row-icon" />
<span class="project-settings-extension-row-name">{props.name}</span>
</div>
<Show when={props.status}>
{(status) => (
<span class="project-settings-extension-row-status">
<span class="project-settings-extension-row-status-dot" />
{status()}
</span>
)}
</Show>
{props.children as any}
</div>
)
const SharedSection: Component<{
count: number
children: unknown
}> = (props) => {
const language = useLanguage()
const [open, setOpen] = createSignal(false)
return (
<Show when={props.count > 0}>
<div class="project-settings-shared">
<button
type="button"
class="project-settings-shared-trigger"
aria-expanded={open()}
onClick={() => setOpen((value) => !value)}
>
<Icon name="chevron-right" classList={{ "project-settings-shared-chevron": true, open: open() }} />
<span>{language.t("project.settings.extensions.shared")}</span>
<span class="project-settings-shared-count">{props.count}</span>
</button>
<Show when={open()}>
<ExtensionCard>{props.children}</ExtensionCard>
</Show>
</div>
</Show>
)
}
export const ProjectSettingsExtensions: Component = () => {
const language = useLanguage()
const serverSDK = useServerSDK()
const directorySDK = useSDK()
const serverSync = useServerSync()
const sync = useSync()
const toggleMcp = useMcpToggle()
const [serverMcp] = createResource(
serverSDK,
(sdk) =>
sdk.api.mcp
.list()
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
.catch(() => ({})),
{ initialValue: {} },
)
const globalMcpNames = createMemo(() =>
[...new Set([...Object.keys(serverSync().data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
)
const projectMcpNames = createMemo(() => {
const shared = new Set(globalMcpNames())
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
if (configured.length > 0) return configured.sort()
return Object.keys(sync().data.mcp ?? {})
.filter((name) => !shared.has(name))
.sort()
})
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
const globalPlugins = createMemo(() => (serverSync().data.config.plugin ?? []).map(pluginName))
const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins())
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
})
const [serverSkills] = createResource(
serverSDK,
(sdk): Promise<SkillItem[]> =>
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
{ initialValue: [] },
)
const [directorySkills] = createResource(
directorySDK,
(sdk): Promise<SkillItem[]> =>
sdk.api.skill
.list({ location: { directory: sdk.directory } })
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
{ initialValue: [] },
)
const projectSkills = createMemo(() => {
const shared = new Set(serverSkills.latest.map(skillKey))
return directorySkills.latest.filter((item) => !shared.has(skillKey(item)))
})
const mcpRows = (items: string[]) => (
<For each={items}>
{(name) => (
<ExtensionRow icon="mcp" name={name}>
<Switch
checked={mcpEnabled(name)}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
hideLabel
onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
>
{name}
</Switch>
</ExtensionRow>
)}
</For>
)
const pluginRows = (items: string[]) => <For each={items}>{(name) => <ExtensionRow icon="cube" name={name} />}</For>
const skillRows = (items: SkillItem[]) => (
<For each={items}>{(item) => <ExtensionRow icon="post-skill" name={item.name} />}</For>
)
return (
<div class="project-settings-extensions">
<div class="project-settings-page-header">
<h2>{language.t("settings.tab.extensions")}</h2>
<span>{language.t("project.settings.extensions.description")}</span>
</div>
<TabsV2 variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
<TabsV2.List>
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
<TabsV2.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</TabsV2.Trigger>
</TabsV2.List>
<TabsV2.Content value="mcps">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<span>{language.t("settings.extensions.manageConfig")}</span>
</div>
<Show when={projectMcpNames().length > 0}>
<ExtensionCard>{mcpRows(projectMcpNames())}</ExtensionCard>
</Show>
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
</div>
</TabsV2.Content>
<TabsV2.Content value="plugins">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<span>{language.t("settings.extensions.manageConfig")}</span>
</div>
<Show when={projectPlugins().length > 0}>
<ExtensionCard>{pluginRows(projectPlugins())}</ExtensionCard>
</Show>
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
</div>
</TabsV2.Content>
<TabsV2.Content value="skills">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<ExternalLink class="project-settings-extension-link" href="https://opencode.ai/docs/skills/">
{language.t("settings.extensions.addSkills")}
</ExternalLink>
</div>
<Show when={projectSkills().length > 0}>
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
</Show>
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection>
</div>
</TabsV2.Content>
<TabsV2.Content value="lsps">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.lsp.detected")}</span>
<span>{language.t("project.settings.extensions.lsp.description")}</span>
</div>
<Show when={sync().data.lsp.length > 0}>
<ExtensionCard>
<For each={sync().data.lsp}>
{(item) => (
<ExtensionRow
icon="code"
name={item.name || item.id}
status={
item.status === "error" ? language.t("project.settings.extensions.setupRequired") : undefined
}
/>
)}
</For>
</ExtensionCard>
</Show>
</div>
</TabsV2.Content>
</TabsV2>
</div>
)
}
@@ -238,7 +238,6 @@ export function createProviderConnectionController(options: {
return { return {
loading: () => integration.loading, loading: () => integration.loading,
integration: () => integration.latest,
methods, methods,
currentMethod, currentMethod,
methodIndex: () => store.methodIndex, methodIndex: () => store.methodIndex,
@@ -96,16 +96,13 @@ export function ServerRow(props: ServerRowProps) {
{(conn) => ( {(conn) => (
<div class="flex flex-row gap-3"> <div class="flex flex-row gap-3">
<span> <span>
<Show {conn().http.username ? (
when={conn().http.username}
fallback={<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>}
>
<span class="text-text-weak">{conn().http.username}</span> <span class="text-text-weak">{conn().http.username}</span>
</Show> ) : (
<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>
)}
</span> </span>
<Show when={conn().http.password}> {conn().http.password && <span class="text-text-weak"></span>}
<span class="text-text-weak"></span>
</Show>
</div> </div>
)} )}
</Show> </Show>
@@ -12,7 +12,7 @@ import { FileVisual } from "./session-sortable-tab"
export function SortableTabV2(props: { export function SortableTabV2(props: {
tab: string tab: string
index: number index: () => number
temporary?: boolean temporary?: boolean
onTabClose: (tab: string) => void onTabClose: (tab: string) => void
onTabDoubleClick?: (tab: string) => void onTabDoubleClick?: (tab: string) => void
@@ -26,7 +26,7 @@ export function SortableTabV2(props: {
return props.tab return props.tab
}, },
get index() { get index() {
return props.index return props.index()
}, },
}) })
const path = createMemo(() => file.pathFromTab(props.tab)) const path = createMemo(() => file.pathFromTab(props.tab))
@@ -14,7 +14,7 @@ import { focusTerminalById } from "@/pages/session/helpers"
export function SortableTerminalTabV2(props: { export function SortableTerminalTabV2(props: {
terminal: LocalPTY terminal: LocalPTY
index: number index: () => number
newLayout: boolean newLayout: boolean
onClose?: () => void onClose?: () => void
}): JSX.Element { }): JSX.Element {
@@ -25,7 +25,7 @@ export function SortableTerminalTabV2(props: {
return props.terminal.id return props.terminal.id
}, },
get index() { get index() {
return props.index return props.index()
}, },
}) })
const [store, setStore] = createStore({ const [store, setStore] = createStore({
@@ -1,6 +1,5 @@
import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js" import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -425,9 +424,9 @@ function SettingsKeybindsV2() {
filtered={controller.catalog.filtered} filtered={controller.catalog.filtered}
title={controller.catalog.title} title={controller.catalog.title}
keybind={controller.catalog.keybind} keybind={controller.catalog.keybind}
active={controller.capture.active()} active={controller.capture.active}
onCapture={controller.capture.toggle} onCapture={controller.capture.toggle}
hasOverrides={controller.settings.hasOverrides()} hasOverrides={controller.settings.hasOverrides}
onReset={controller.settings.reset} onReset={controller.settings.reset}
/> />
) )
@@ -438,9 +437,9 @@ function SettingsKeybindsV2View(props: {
filtered: (query: string) => Map<KeybindGroup, string[]> filtered: (query: string) => Map<KeybindGroup, string[]>
title: (id: string) => string title: (id: string) => string
keybind: (id: string) => string keybind: (id: string) => string
active: string | null active: () => string | null
onCapture: (id: string) => void onCapture: (id: string) => void
hasOverrides: boolean hasOverrides: () => boolean
onReset: () => void onReset: () => void
}) { }) {
const language = useLanguage() const language = useLanguage()
@@ -452,11 +451,8 @@ function SettingsKeybindsV2View(props: {
<> <>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked"> <div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row"> <div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1"> <h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2> <ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.shortcuts.description")}</span>
</div>
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides}>
{language.t("settings.shortcuts.reset.button")} {language.t("settings.shortcuts.reset.button")}
</ButtonV2> </ButtonV2>
</div> </div>
@@ -502,12 +498,12 @@ function SettingsKeybindsV2View(props: {
data-keybind-id={id} data-keybind-id={id}
classList={{ classList={{
"settings-v2-keybind-button": true, "settings-v2-keybind-button": true,
"settings-v2-keybind-button--active": props.active === id, "settings-v2-keybind-button--active": props.active() === id,
}} }}
onClick={() => props.onCapture(id)} onClick={() => props.onCapture(id)}
> >
<Show <Show
when={props.active === id} when={props.active() === id}
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")} fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
> >
{language.t("settings.shortcuts.pressKeys")} {language.t("settings.shortcuts.pressKeys")}
@@ -678,6 +674,8 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
</Show> </Show>
) )
const List = props.v2 ? SettingsListV2 : SettingsList
const groups = ( const groups = (
<div <div
classList={{ classList={{
@@ -702,7 +700,7 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
> >
{language.t(groupKey[group])} {language.t(groupKey[group])}
</h3> </h3>
<Dynamic component={props.v2 ? SettingsListV2 : SettingsList}> <List>
<For each={filtered().get(group) ?? []}> <For each={filtered().get(group) ?? []}>
{(id) => ( {(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none"> <div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
@@ -737,7 +735,7 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
</div> </div>
)} )}
</For> </For>
</Dynamic> </List>
</div> </div>
</Show> </Show>
)} )}
@@ -1,29 +0,0 @@
import { type ParentProps, Show } from "solid-js"
import { useGlobal } from "@/context/global"
import { ModelsProvider } from "@/context/models"
import { ServerConnection } from "@/context/server"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
export function SettingsServerScope(props: ParentProps<{ directory?: string }>) {
const global = useGlobal()
return (
<Show when={global.settings.server.selected()} keyed fallback={props.children}>
{(server) => (
<SettingsServerDataScope server={server} directory={props.directory}>
{props.children}
</SettingsServerDataScope>
)}
</Show>
)
}
export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any; directory?: string }>) {
return (
<ServerSDKProvider server={props.server}>
<ServerSyncProvider server={props.server}>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</ServerSyncProvider>
</ServerSDKProvider>
)
}
@@ -1,134 +0,0 @@
import { Component, createMemo } from "solid-js"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useLanguage } from "@/context/language"
import { ExternalLink } from "../external-link"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import { createAppearanceSettingsController, type AppearanceSettingsController } from "./general-controllers"
import "./settings-v2.css"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const fontSettings = {
ui: {
action: "settings-ui-font",
title: "settings.general.row.uiFont.title",
description: "settings.general.row.uiFont.description",
font: "ui",
input: "setUI",
},
code: {
action: "settings-code-font",
title: "settings.general.row.font.title",
description: "settings.general.row.font.description",
font: "code",
input: "setCode",
},
terminal: {
action: "settings-terminal-font",
title: "settings.general.row.terminalFont.title",
description: "settings.general.row.terminalFont.description",
font: "terminal",
input: "setTerminal",
},
} as const
const FontSetting: Component<{
kind: "ui" | "code" | "terminal"
fonts: AppearanceSettingsController["fonts"]
}> = (props) => {
const language = useLanguage()
const config = () => fontSettings[props.kind]
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action={config().action}
type="text"
appearance="base"
value={props.fonts[config().font]().value}
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
placeholder={props.fonts[config().font]().placeholder}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t(config().title)}
style={{ "font-family": props.fonts[config().font]().family }}
/>
</div>
</SettingsRowV2>
)
}
export const SettingsAppearanceV2: Component = () => {
const language = useLanguage()
const appearance = createAppearanceSettingsController()
return (
<>
<div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.general.section.appearance")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.appearance.description")}</span>
</div>
</div>
</div>
<div class="settings-v2-tab-body">
<div class="settings-v2-section">
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<SelectV2
appearance="inline"
data-action="settings-color-scheme"
options={schemeOptions}
current={schemeOptions.find((option) => option === appearance.scheme.current())}
placement="bottom-end"
gutter={6}
label={(option) => {
if (option === "system") return language.t("theme.scheme.system")
if (option === "light") return language.t("theme.scheme.light")
return language.t("theme.scheme.dark")
}}
onSelect={(option) => option && appearance.scheme.select(option)}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
{language.t("common.learnMore")}
</ExternalLink>
</>
}
>
<SelectV2
appearance="inline"
data-action="settings-theme"
options={appearance.theme.options()}
current={appearance.theme.current()}
placement="bottom-end"
gutter={6}
value={(option) => option.id}
label={(option) => option.name}
onSelect={appearance.theme.select}
/>
</SettingsRowV2>
<FontSetting kind="ui" fonts={appearance.fonts} />
<FontSetting kind="code" fonts={appearance.fonts} />
<FontSetting kind="terminal" fonts={appearance.fonts} />
</SettingsListV2>
</div>
</div>
</>
)
}
@@ -5,22 +5,15 @@ import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { SettingsGeneralV2 } from "./general" import { SettingsGeneralV2 } from "./general"
import { SettingsAppearanceV2 } from "./appearance"
import { SettingsKeybinds } from "../settings-keybinds" import { SettingsKeybinds } from "../settings-keybinds"
import { SettingsNotificationsV2 } from "./notifications"
import { SettingsProvidersV2 } from "./providers" import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models" import { SettingsModelsV2 } from "./models"
import "./settings-v2.css"
import { SettingsServersV2 } from "./servers" import { SettingsServersV2 } from "./servers"
import { SettingsProjectsV2 } from "./projects"
import { SettingsExtensionsV2 } from "./extensions"
import { SettingsServerScope } from "../settings-server-picker"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useGlobal } from "@/context/global"
import { ServerConnection } from "@/context/server"
import "./settings-v2.css"
export const DialogSettings: Component<{ export const DialogSettings: Component<{
sessionID?: string sessionID?: string
@@ -32,13 +25,8 @@ export const DialogSettings: Component<{
const layout = useLayout() const layout = useLayout()
const tabs = useTabs() const tabs = useTabs()
const serverSync = useServerSync() const serverSync = useServerSync()
const global = useGlobal()
const currentServer = global.servers.list().find((server) => global.ensureServerCtx(server).sync === serverSync())
if (currentServer) global.settings.server.set(ServerConnection.key(currentServer))
const [tab, setTab] = createSignal(props.defaultValue ?? "general") const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const directory = createMemo(() => { const directory = createMemo(() => {
const server = global.settings.server.selected()
if (!server || serverSync() !== global.ensureServerCtx(server).sync) return
const route = layout.route() const route = layout.route()
if (route.type === "dir-new-sesssion") return route.dir if (route.type === "dir-new-sesssion") return route.dir
if (route.type === "draft") { if (route.type === "draft") {
@@ -64,92 +52,62 @@ export const DialogSettings: Component<{
> >
<TabsV2.List> <TabsV2.List>
<div class="flex flex-col justify-between h-full w-full"> <div class="flex flex-col justify-between h-full w-full">
<div class="flex flex-col gap-4 w-full"> <div class="flex flex-col gap-3 w-full">
{/* Group 1: Preferences */} <div class="flex flex-col gap-3">
<div class="flex flex-col gap-1 w-full"> <div class="flex flex-col gap-1.5">
<TabsV2.Trigger value="general"> <TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
<Icon name="sliders" /> <div class="flex flex-col gap-1.5 w-full">
{language.t("settings.tab.preferences")} <TabsV2.Trigger value="general">
</TabsV2.Trigger> <Icon name="sliders" />
<TabsV2.Trigger value="appearance"> {language.t("settings.tab.general")}
<Icon name="appearance" /> </TabsV2.Trigger>
{language.t("settings.general.section.appearance")} <TabsV2.Trigger value="shortcuts">
</TabsV2.Trigger> <Icon name="keyboard" />
<TabsV2.Trigger value="notifications"> {language.t("settings.tab.shortcuts")}
<Icon name="notifications" /> </TabsV2.Trigger>
{language.t("settings.tab.notifications")} </div>
</TabsV2.Trigger> </div>
<TabsV2.Trigger value="shortcuts">
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</TabsV2.Trigger>
</div>
{/* Group 2: Environment & Workspaces */} <div class="flex flex-col gap-1.5">
<div class="flex flex-col gap-1 w-full"> <TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
<TabsV2.Trigger value="servers"> <div class="flex flex-col gap-1.5 w-full">
<Icon name="server" /> <TabsV2.Trigger value="servers">
{language.t("status.popover.tab.servers")} <Icon name="server" />
</TabsV2.Trigger> {language.t("status.popover.tab.servers")}
<TabsV2.Trigger value="projects"> </TabsV2.Trigger>
<Icon name="folder" /> <TabsV2.Trigger value="providers">
{language.t("settings.tab.projects")} <Icon name="providers" />
</TabsV2.Trigger> {language.t("settings.providers.title")}
</div> </TabsV2.Trigger>
<TabsV2.Trigger value="models">
{/* Group 3: Capabilities & Extensions */} <Icon name="models" />
<div class="flex flex-col gap-1 w-full"> {language.t("settings.models.title")}
<TabsV2.Trigger value="providers"> </TabsV2.Trigger>
<Icon name="providers" /> </div>
{language.t("settings.providers.title")} </div>
</TabsV2.Trigger>
<TabsV2.Trigger value="models">
<Icon name="models" />
{language.t("settings.models.title")}
</TabsV2.Trigger>
<TabsV2.Trigger value="extensions">
<Icon name="extensions" />
{language.t("settings.tab.extensions")}
</TabsV2.Trigger>
</div> </div>
</div> </div>
<div class="settings-v2-nav-footer"> <div class="settings-v2-nav-footer">
<span>{language.t("app.name.desktop")}</span> <span>{language.t("app.name.desktop")}</span>
<span>v{platform.version}</span> <span>v{platform.version}</span>
</div> </div>
</div> </div>
</TabsV2.List> </TabsV2.List>
<TabsV2.Content value="general" class="settings-v2-panel"> <TabsV2.Content value="general" class="settings-v2-panel">
<SettingsGeneralV2 sessionID={props.sessionID} /> <SettingsGeneralV2 sessionID={props.sessionID} />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="appearance" class="settings-v2-panel">
<SettingsAppearanceV2 />
</TabsV2.Content>
<TabsV2.Content value="notifications" class="settings-v2-panel">
<SettingsNotificationsV2 />
</TabsV2.Content>
<TabsV2.Content value="shortcuts" class="settings-v2-panel"> <TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds v2 /> <SettingsKeybinds v2 />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel"> <TabsV2.Content value="servers" class="settings-v2-panel">
<SettingsServersV2 /> <SettingsServersV2 />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="projects" class="settings-v2-panel"> <TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProjectsV2 /> <SettingsProvidersV2 directory={directory} onBack={showProviders} />
</TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 />
</TabsV2.Content> </TabsV2.Content>
<SettingsServerScope directory={directory()}>
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 directory={directory()} onBack={showProviders} />
</TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 />
</TabsV2.Content>
<TabsV2.Content value="extensions" class="settings-v2-panel">
<SettingsExtensionsV2 />
</TabsV2.Content>
</SettingsServerScope>
</TabsV2> </TabsV2>
</Dialog> </Dialog>
) )
@@ -1,156 +0,0 @@
import { Component, For, createMemo, createResource } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { ExternalLink } from "../external-link"
import { InlineServerSelect } from "./parts/server-select"
import "./settings-v2.css"
interface McpRowItem {
name: string
enabled: boolean
}
interface PluginRowItem {
name: string
}
export const SettingsExtensionsV2: Component = () => {
const language = useLanguage()
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const mcps = createMemo<McpRowItem[]>(() => {
const configMcp = serverSync().data.config.mcp ?? {}
return Object.entries(configMcp).map(([name, config]) => ({
name,
enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false,
}))
})
const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
const before = serverSync().data.config.mcp ?? {}
const config = before[item.name]
if (typeof config !== "object" || config === null) return
const next = { ...before, [item.name]: { ...config, enabled: checked } }
serverSync().set("config", "mcp", next)
void serverSync()
.updateConfig({ mcp: next })
.catch(() => serverSync().set("config", "mcp", before))
}
const plugins = createMemo<PluginRowItem[]>(() => {
const raw = serverSync().data.config.plugin ?? []
return raw.map((item) => {
const name = typeof item === "string" ? item : item[0]
return { name }
})
})
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), {
initialValue: [],
})
return (
<>
<div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.extensions")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.extensions.description")}</span>
</div>
<InlineServerSelect />
</div>
</div>
<div class="settings-v2-tab-body">
<TabsV2 variant="pill" defaultValue="mcps" class="settings-v2-extensions-tabs">
<TabsV2.List>
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
</TabsV2.List>
<TabsV2.Content value="mcps">
<div class="settings-v2-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
{language.t("settings.extensions.availableAll")}
</span>
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<For each={mcps()}>
{(item) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<Icon name="mcp" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{item.name}</span>
</div>
<Switch checked={item.enabled} onChange={(checked) => handleMcpToggle(item, checked)} hideLabel>
{item.name}
</Switch>
</div>
)}
</For>
</div>
</div>
</TabsV2.Content>
<TabsV2.Content value="plugins">
<div class="settings-v2-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
{language.t("settings.extensions.availableAll")}
</span>
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<For each={plugins()}>
{(plugin) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<Icon name="cube" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate font-mono">{plugin.name}</span>
</div>
</div>
)}
</For>
</div>
</div>
</TabsV2.Content>
<TabsV2.Content value="skills">
<div class="settings-v2-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
{language.t("settings.extensions.availableAll")}
</span>
<ExternalLink
class="text-13-regular text-v2-text-accent hover:underline"
href="https://opencode.ai/docs/skills/"
>
{language.t("settings.extensions.addSkills")}
</ExternalLink>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<For each={skills()}>
{(skill) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<Icon name="post-skill" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{skill.name}</span>
</div>
</div>
)}
</For>
</div>
</div>
</TabsV2.Content>
</TabsV2>
</div>
</>
)
}
@@ -279,6 +279,8 @@ export const SettingsGeneralV2: Component<{
const updater = useUpdaterAction() const updater = useUpdaterAction()
const permissionScope = createPermissionScopeController(() => props.sessionID) const permissionScope = createPermissionScopeController(() => props.sessionID)
const shell = createShellSettingsController() const shell = createShellSettingsController()
const appearance = createAppearanceSettingsController()
const sounds = createSoundSettingsController()
const desktop = createMemo(() => platform.platform === "desktop") const desktop = createMemo(() => platform.platform === "desktop")
const [pinchZoom, { mutate: setPinchZoom }] = createResource( const [pinchZoom, { mutate: setPinchZoom }] = createResource(
@@ -296,7 +298,6 @@ export const SettingsGeneralV2: Component<{
const GeneralSection = () => ( const GeneralSection = () => (
<div class="settings-v2-section"> <div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.general")}</h3>
<SettingsListV2> <SettingsListV2>
<LanguageSetting /> <LanguageSetting />
@@ -509,19 +510,18 @@ export const SettingsGeneralV2: Component<{
return ( return (
<> <>
<div class="settings-v2-tab-header"> <div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row"> <h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.preferences")}</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t("settings.preferences.description")}
</span>
</div>
</div>
</div> </div>
<div class="settings-v2-tab-body"> <div class="settings-v2-tab-body">
<GeneralSection /> <GeneralSection />
<AppearanceSection controller={appearance} />
<NotificationsSection />
<SoundsSection controller={sounds} />
<Show when={desktop()}> <Show when={desktop()}>
<UpdatesSection /> <UpdatesSection />
</Show> </Show>
@@ -11,7 +11,6 @@ import { useModels } from "@/context/models"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { InlineServerSelect } from "./parts/server-select"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row" import { SettingsRowV2 } from "./parts/row"
import "./settings-v2.css" import "./settings-v2.css"
@@ -54,13 +53,7 @@ export const SettingsModelsV2: Component = () => {
return ( return (
<> <>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked"> <div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row"> <h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.models.description")}</span>
</div>
<InlineServerSelect />
</div>
<div class="settings-v2-tab-search"> <div class="settings-v2-tab-search">
<TextInputV2 <TextInputV2
type="search" type="search"
@@ -1,124 +0,0 @@
import { Component } from "solid-js"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import { createSoundSettingsController, soundOptions, type SoundSettingsController } from "./general-controllers"
import "./settings-v2.css"
const soundSettings = {
agent: {
action: "settings-sounds-agent",
title: "settings.general.sounds.agent.title",
description: "settings.general.sounds.agent.description",
},
permissions: {
action: "settings-sounds-permissions",
title: "settings.general.sounds.permissions.title",
description: "settings.general.sounds.permissions.description",
},
errors: {
action: "settings-sounds-errors",
title: "settings.general.sounds.errors.title",
description: "settings.general.sounds.errors.description",
},
} as const
const SoundSetting: Component<{
kind: "agent" | "permissions" | "errors"
channel: SoundSettingsController["agent"]
}> = (props) => {
const language = useLanguage()
const config = () => soundSettings[props.kind]
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<SelectV2
appearance="inline"
data-action={config().action}
options={soundOptions}
current={props.channel.current()}
value={(option) => option.id}
label={(option) => language.t(option.label)}
onHighlight={props.channel.highlight}
onSelect={props.channel.select}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
)
}
export const SettingsNotificationsV2: Component = () => {
const language = useLanguage()
const settings = useSettings()
const sounds = createSoundSettingsController()
return (
<>
<div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.notifications")}</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t("settings.notifications.description")}
</span>
</div>
</div>
</div>
<div class="settings-v2-tab-body">
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
>
<div data-action="settings-notifications-agent">
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.notifications.permissions.title")}
description={language.t("settings.general.notifications.permissions.description")}
>
<div data-action="settings-notifications-permissions">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.notifications.errors.title")}
description={language.t("settings.general.notifications.errors.description")}
>
<div data-action="settings-notifications-errors">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
<SettingsListV2>
<SoundSetting kind="agent" channel={sounds.agent} />
<SoundSetting kind="permissions" channel={sounds.permissions} />
<SoundSetting kind="errors" channel={sounds.errors} />
</SettingsListV2>
</div>
</div>
</>
)
}
@@ -1,49 +0,0 @@
import { Show, createMemo, type Component } from "solid-js"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/server"
const allServers = { type: "all" } as const
type ServerOption = ServerConnection.Any | typeof allServers
export const InlineServerSelect: Component<{
all?: {
label: string
selected: () => boolean
onSelect: () => void
}
onServerSelect?: () => void
}> = (props) => {
const global = useGlobal()
const options = createMemo<ServerOption[]>(() => [...(props.all ? [allServers] : []), ...global.servers.list()])
const current = () => (props.all?.selected() ? allServers : global.settings.server.selected())
return (
<Show when={options().length > 1}>
<SelectV2
appearance="inline"
data-action="settings-server-select"
options={options()}
current={current()}
value={(server) => (server.type === "all" ? server.type : ServerConnection.key(server))}
label={(server) =>
server.type === "all" ? (props.all?.label ?? "") : serverName(server) || ServerConnection.key(server)
}
optionDisabled={(server) =>
server.type === "all" ? false : global.servers.health[ServerConnection.key(server)]?.healthy === false
}
placement="bottom-end"
gutter={6}
onSelect={(server) => {
if (!server) return
if (server.type === "all") {
props.all?.onSelect()
return
}
global.settings.server.set(ServerConnection.key(server))
props.onServerSelect?.()
}}
/>
</Show>
)
}
@@ -1,139 +0,0 @@
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useGlobal } from "@/context/global"
import { getProjectAvatarVariant } from "@/context/layout"
import { ServerConnection, serverName } from "@/context/server"
import { displayName } from "@/pages/layout/helpers"
import { InlineServerSelect } from "./parts/server-select"
import { DialogEditProjectV2 } from "../dialog-edit-project-v2"
import "./settings-v2.css"
export const SettingsProjectsV2: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const global = useGlobal()
const [allServers, setAllServers] = createSignal(true)
const selected = global.settings.server.selected
const projects = createMemo(() => {
const server = selected()
if (!server) return []
return global.ensureServerCtx(server).projects.list()
})
type ProjectItem = ReturnType<typeof projects>[number]
const groups = createMemo(() =>
global.servers
.list()
.map((server) => ({ server, projects: global.ensureServerCtx(server).projects.list() }))
.filter((group) => group.projects.length > 0),
)
const openProjectSettings = (project: ProjectItem, server = selected()) => {
if (!server) return
dialog.push(() => <DialogEditProjectV2 project={project} server={server} />)
}
const ProjectRow: Component<{ project: ProjectItem; server: ServerConnection.Any }> = (props) => {
const name = () => displayName(props.project)
const color = () => getProjectAvatarVariant(props.project.icon?.color)
return (
<div
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] cursor-pointer transition-all hover:bg-v2-background-bg-layer-01"
onClick={() => openProjectSettings(props.project, props.server)}
>
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<ProjectAvatar fallback={name()} variant={color()} class="shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
</div>
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
icon={<IconV2 name="settings-gear" size="small" class="text-v2-icon-icon-muted" />}
onClick={(event: MouseEvent) => {
event.stopPropagation()
openProjectSettings(props.project, props.server)
}}
/>
</div>
</div>
)
}
return (
<>
<div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.projects.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
</div>
<InlineServerSelect
all={{
label: language.t("settings.projects.server.all"),
selected: allServers,
onSelect: () => setAllServers(true),
}}
onServerSelect={() => setAllServers(false)}
/>
</div>
</div>
<div class="settings-v2-tab-body">
<Show
when={allServers()}
fallback={
<div class="flex flex-col gap-2 w-full">
<Show
when={projects().length > 0}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<Show when={selected()} keyed>
{(server) => (
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
)}
</Show>
</Show>
</div>
}
>
<div class="flex flex-col gap-8 w-full">
<Show
when={groups().length > 0}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<For each={groups()}>
{(group) => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">
{serverName(group.server) || ServerConnection.key(group.server)}
</h3>
<div class="flex flex-col gap-2 w-full">
<For each={group.projects}>
{(project) => <ProjectRow project={project} server={group.server} />}
</For>
</div>
</div>
)}
</For>
</Show>
</div>
</Show>
</div>
</>
)
}
@@ -4,14 +4,12 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js" import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider" import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
import { DialogCustomProvider } from "../dialog-custom-provider" import { DialogCustomProvider } from "../dialog-custom-provider"
import { SettingsServerScope } from "../settings-server-picker"
import { InlineServerSelect } from "./parts/server-select"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import "./settings-v2.css" import "./settings-v2.css"
@@ -32,35 +30,25 @@ const PROVIDER_NOTES = [
const PROVIDER_ICON_SIZE = 16 const PROVIDER_ICON_SIZE = 16
export const SettingsProvidersV2: Component<{ export const SettingsProvidersV2: Component<{
directory: string | undefined directory: Accessor<string | undefined>
onBack?: () => void onBack?: () => void
}> = (props) => { }> = (props) => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
const providers = useProviders(() => props.directory) const providers = useProviders(props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack }) const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => { const connect = (provider?: string) => {
providerConnect.select(provider) providerConnect.select(provider)
void dialog.show(() => ( void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
<SettingsServerScope directory={props.directory}>
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
</SettingsServerScope>
))
} }
const connected = createMemo(() => { const connected = createMemo(() => {
return providers.connected().filter( return providers
(provider) => .connected()
provider.id !== "opencode" || .filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
Object.values(provider.models).some((model) => {
if (typeof model !== "object" || model === null || !("cost" in model)) return false
const cost = model.cost
return typeof cost === "object" && cost !== null && "input" in cost
}),
)
}) })
const popular = createMemo(() => { const popular = createMemo(() => {
@@ -104,8 +92,31 @@ export const SettingsProvidersV2: Component<{
return true return true
} }
const disableProvider = async (providerID: string, name: string) => {
return
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next)
await serverSync()
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
})
})
.catch((err: unknown) => {
serverSync().set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
const disconnect = async (providerID: string, name: string) => { const disconnect = async (providerID: string, name: string) => {
const location = props.directory ? { directory: props.directory } : undefined const location = props.directory() ? { directory: props.directory() } : undefined
await serverSdk() await serverSdk()
.api.integration.get({ integrationID: providerID, location }) .api.integration.get({ integrationID: providerID, location })
.then(async (integration) => { .then(async (integration) => {
@@ -130,13 +141,7 @@ export const SettingsProvidersV2: Component<{
return ( return (
<> <>
<div class="settings-v2-tab-header"> <div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row"> <h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.providers.description")}</span>
</div>
<InlineServerSelect />
</div>
</div> </div>
<div class="settings-v2-tab-body settings-v2-providers"> <div class="settings-v2-tab-body settings-v2-providers">
@@ -239,11 +244,7 @@ export const SettingsProvidersV2: Component<{
variant="neutral" variant="neutral"
icon="plus" icon="plus"
onClick={() => { onClick={() => {
dialog.show(() => ( dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
<SettingsServerScope directory={props.directory}>
<DialogCustomProvider onBack={dialog.close} />
</SettingsServerScope>
))
}} }}
> >
{language.t("common.connect")} {language.t("common.connect")}
@@ -53,10 +53,7 @@ export const SettingsServersV2: Component = () => {
classList={{ "settings-v2-tab-header--stacked": showSearch() }} classList={{ "settings-v2-tab-header--stacked": showSearch() }}
> >
<div class="settings-v2-tab-header-row"> <div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1"> <h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.servers.description")}</span>
</div>
<AddServerMenu onAddServer={openAdd} /> <AddServerMenu onAddServer={openAdd} />
</div> </div>
<Show when={showSearch()}> <Show when={showSearch()}>
@@ -39,14 +39,6 @@
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent); background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
} }
.settings-v2-tab-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.settings-v2-tab-title { .settings-v2-tab-title {
font-size: 15px; font-size: 15px;
font-weight: 640; font-weight: 640;
@@ -182,13 +174,13 @@
max-width: 100%; max-width: 100%;
} }
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] > [data-slot="tabs-v2-list"] { [data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-list"] {
background-color: var(--v2-background-bg-layer-01); background-color: var(--v2-background-bg-layer-01);
} }
@media (max-width: 639px) { @media (max-width: 639px) {
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] .settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] { [data-slot="tabs-v2-list"] {
width: 144px; width: 144px;
min-width: 144px; min-width: 144px;
padding-inline: 8px; padding-inline: 8px;
@@ -735,24 +727,3 @@
line-height: 1; line-height: 1;
color: var(--v2-state-fg-danger); color: var(--v2-state-fg-danger);
} }
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: 280px;
padding-inline: 0 !important;
}
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
display: none;
}
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger-wrapper"] {
border-radius: 6px;
}
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger"] {
padding-inline: 8px;
}
@@ -1,7 +1,16 @@
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js" import {
type Accessor,
createEffect,
createMemo,
createResource,
For,
type JSXElement,
onCleanup,
Show,
} from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
@@ -100,7 +109,7 @@ type ServerStatusItem = {
onSelect: () => void onSelect: () => void
} }
export function StatusPopoverBody(props: { shown: boolean }) { export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
const global = useGlobal() const global = useGlobal()
@@ -116,6 +125,10 @@ export function StatusPopoverBody(props: { shown: boolean }) {
}) })
} }
createEffect(() => {
if (!props.shown()) return
})
let dialogRun = 0 let dialogRun = 0
let dialogDead = false let dialogDead = false
onCleanup(() => { onCleanup(() => {
@@ -134,7 +147,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
const lspItems = createMemo(() => sync().data.lsp ?? []) const lspItems = createMemo(() => sync().data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length) const lspCount = createMemo(() => lspItems().length)
const [pluginList] = createResource( const [pluginList] = createResource(
() => (props.shown ? sdk().directory : undefined), () => (props.shown() ? sdk().directory : undefined),
(directory) => (directory) =>
sdk() sdk()
.api.plugin.list({ location: { directory } }) .api.plugin.list({ location: { directory } })
@@ -74,7 +74,7 @@ export function StatusPopover() {
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" /> <div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
} }
> >
<Body shown={shown()} /> <Body shown={shown} />
</Suspense> </Suspense>
</Show> </Show>
</Popover> </Popover>
@@ -114,7 +114,7 @@ function DirectoryStatusPopover() {
onOpenChange: setShown, onOpenChange: setShown,
body: () => ( body: () => (
<StatusPopoverBody shown={shown()}> <StatusPopoverBody shown={shown()}>
<Body shown={shown()} /> <Body shown={shown} />
</StatusPopoverBody> </StatusPopoverBody>
), ),
})) }))
-1
View File
@@ -175,7 +175,6 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings() const settings = useSettings()
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
// Intentional mount-time capture: the imperative xterm/WebSocket lifecycle needs stable values, and Terminal remounts when the SDK scope changes.
const directory = sdk().directory const directory = sdk().directory
const url = sdk().url const url = sdk().url
let container!: HTMLDivElement let container!: HTMLDivElement
@@ -22,14 +22,14 @@ export function TabNavItem(props: {
ref?: Ref<HTMLDivElement> ref?: Ref<HTMLDivElement>
href: string href: string
server: ServerConnection.Key server: ServerConnection.Key
session: SessionInfo | undefined session: () => SessionInfo | undefined
fallbackTitle?: string fallbackTitle?: string
onRename: (title: string) => Promise<void> onRename: (title: string) => Promise<void>
onClose: () => void onClose: () => void
onNavigate: () => void onNavigate: () => void
active?: boolean active?: boolean
forceTruncate?: boolean forceTruncate?: boolean
suppressNavigation?: boolean suppressNavigation?: () => boolean
dragging?: boolean dragging?: boolean
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
@@ -52,22 +52,22 @@ export function TabNavItem(props: {
if (conn) return global.ensureServerCtx(conn) if (conn) return global.ensureServerCtx(conn)
}) })
const project = createMemo(() => { const project = createMemo(() => {
const session = props.session const session = props.session()
if (!session) return if (!session) return
return projectForSession(session, serverCtx()?.projects.list() ?? []) return projectForSession(session, serverCtx()?.projects.list() ?? [])
}) })
const title = createMemo(() => { const title = createMemo(() => {
const session = props.session const session = props.session()
return session ? sessionLabel(session) : props.fallbackTitle 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.location.directory })
}) })
const previewPath = createMemo(() => { const previewPath = createMemo(() => {
const session = props.session const session = props.session()
if (!session) return if (!session) return
const home = serverCtx()?.sync.data.path.home const home = serverCtx()?.sync.data.path.home
return home ? session.location.directory.replace(home, "~") : session.location.directory return home ? session.location.directory.replace(home, "~") : session.location.directory
@@ -80,7 +80,7 @@ export function TabNavItem(props: {
}) })
const [popoverOpen, setPopoverOpen] = createSignal(false) const [popoverOpen, setPopoverOpen] = createSignal(false)
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session()
const measureTitleOverflow = () => { const measureTitleOverflow = () => {
if (!titleEl || editing()) { if (!titleEl || editing()) {
@@ -121,7 +121,7 @@ export function TabNavItem(props: {
const closeRename = async (save: boolean) => { const closeRename = async (save: boolean) => {
if (rename.isPending || !editing()) return if (rename.isPending || !editing()) return
const original = props.session?.title ?? "" const original = props.session()?.title ?? ""
const next = (titleEl.textContent ?? "").trim() const next = (titleEl.textContent ?? "").trim()
titleEl.scrollLeft = 0 titleEl.scrollLeft = 0
@@ -146,7 +146,7 @@ export function TabNavItem(props: {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
const session = props.session const session = props.session()
if (!session) return if (!session) return
titleEl.textContent = session.title ?? "" titleEl.textContent = session.title ?? ""
setEditing(true) setEditing(true)
@@ -213,7 +213,7 @@ export function TabNavItem(props: {
// Navigate on mousedown to shave the press-release delay off tab switches. // Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return if (event.button !== 0) return
if (editing()) return if (editing()) return
if (props.suppressNavigation) return if (props.suppressNavigation?.()) return
props.onNavigate() props.onNavigate()
}} }}
onClick={(event) => { onClick={(event) => {
@@ -221,14 +221,14 @@ export function TabNavItem(props: {
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation. // Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return if (event.detail > 0) return
if (editing()) return if (editing()) return
if (props.suppressNavigation) return if (props.suppressNavigation?.()) return
props.onNavigate() props.onNavigate()
}} }}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
> >
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center"> <span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show <Show
when={props.session} when={props.session()}
keyed keyed
fallback={ fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" /> <span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
@@ -267,7 +267,7 @@ export function TabNavItem(props: {
} }
if (event.key !== "Escape") return if (event.key !== "Escape") return
event.preventDefault() event.preventDefault()
titleEl.textContent = props.session?.title ?? "" titleEl.textContent = props.session()?.title ?? ""
void closeRename(false) void closeRename(false)
}} }}
onBlur={() => void closeRename(true)} onBlur={() => void closeRename(true)}
@@ -308,7 +308,7 @@ export function TabNavItem(props: {
}} }}
data={{ data={{
projectName: projectName(), projectName: projectName(),
title: props.session?.title, title: props.session()?.title,
path: previewPath(), path: previewPath(),
serverName: serverLabel(), serverName: serverLabel(),
}} }}
@@ -323,7 +323,7 @@ export function DraftTabItem(props: {
active?: boolean active?: boolean
onNavigate: () => void onNavigate: () => void
onClose: () => void onClose: () => void
suppressNavigation?: boolean suppressNavigation?: () => boolean
dragging?: boolean dragging?: boolean
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
@@ -366,14 +366,14 @@ export function DraftTabItem(props: {
onMouseDown={(event) => { onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches. // Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return if (event.button !== 0) return
if (props.suppressNavigation) return if (props.suppressNavigation?.()) return
props.onNavigate() props.onNavigate()
}} }}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation. // Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return if (event.detail > 0) return
if (props.suppressNavigation) return if (props.suppressNavigation?.()) return
props.onNavigate() props.onNavigate()
}} }}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]" class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
@@ -24,10 +24,10 @@ import type { SessionInfo } from "@opencode-ai/client/promise"
function SessionTabSlot(props: { function SessionTabSlot(props: {
tab: SessionTab tab: SessionTab
id: string id: string
index: number index: () => number
active: boolean active: () => boolean
forceTruncate: boolean forceTruncate: boolean
session: SessionInfo | undefined session: () => SessionInfo | undefined
fallbackTitle?: string fallbackTitle?: string
onRename: (title: string) => Promise<void> onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void onNavigate: (element: HTMLDivElement) => void
@@ -38,7 +38,7 @@ function SessionTabSlot(props: {
return props.id return props.id
}, },
get index() { get index() {
return props.index return props.index()
}, },
}) })
let ref!: HTMLDivElement let ref!: HTMLDivElement
@@ -48,7 +48,7 @@ function SessionTabSlot(props: {
ref={sortable.ref} ref={sortable.ref}
data-titlebar-tab-slot data-titlebar-tab-slot
data-tab-key={props.id} data-tab-key={props.id}
data-active={props.active} data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink" class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
> >
<TabNavItem <TabNavItem
@@ -62,7 +62,7 @@ function SessionTabSlot(props: {
onRename={props.onRename} onRename={props.onRename}
onNavigate={() => props.onNavigate(ref)} onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose} onClose={props.onClose}
active={props.active} active={props.active()}
forceTruncate={props.forceTruncate} forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()} dragging={sortable.isDragSource()}
/> />
@@ -73,34 +73,34 @@ function SessionTabSlot(props: {
function SessionTabEntry(props: { function SessionTabEntry(props: {
tab: SessionTab tab: SessionTab
id: string id: string
index: number index: () => number
active: boolean active: () => boolean
forceTruncate: boolean forceTruncate: boolean
serverCtx: ServerCtx | undefined serverCtx: () => ServerCtx | undefined
onVisibleChange: (visible: boolean) => void onVisibleChange: (visible: boolean) => void
onNavigate: (element: HTMLDivElement) => void onNavigate: (element: HTMLDivElement) => void
onClose: () => void onClose: () => void
}) { }) {
const tabs = useTabs() const tabs = useTabs()
const language = useLanguage() const language = useLanguage()
const sdk = createMemo(() => props.serverCtx?.sdk ?? null) const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx?.sync.session.peek(props.tab.sessionId)) const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id]) const persisted = createMemo(() => tabs.info[props.id])
const [loadedSession] = createResource( const [loadedSession] = createResource(
() => { () => {
const ctx = props.serverCtx const ctx = props.serverCtx()
return ctx ? { id: props.tab.sessionId, ctx } : null return ctx ? { id: props.tab.sessionId, ctx } : null
}, },
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined), ({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
) )
const session = createMemo(() => cachedSession() ?? loadedSession()) const session = createMemo(() => cachedSession() ?? loadedSession())
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session()) const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title) const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
let prefetched = false let prefetched = false
const rename = async (title: string) => { const rename = async (title: string) => {
const value = session() const value = session()
const ctx = props.serverCtx const ctx = props.serverCtx()
if (!value || !ctx) return if (!value || !ctx) return
ctx.sync.session.remember({ ...value, title }) ctx.sync.session.remember({ ...value, title })
@@ -108,7 +108,7 @@ function SessionTabEntry(props: {
await ctx.sdk.api.session.rename({ sessionID: value.id, title }) await ctx.sdk.api.session.rename({ sessionID: value.id, title })
} catch (err) { } catch (err) {
const current = session() const current = session()
const currentCtx = props.serverCtx const currentCtx = props.serverCtx()
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title }) if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
showToast({ showToast({
title: language.t("common.requestFailed"), title: language.t("common.requestFailed"),
@@ -120,7 +120,7 @@ function SessionTabEntry(props: {
createEffect(() => props.onVisibleChange(visible())) createEffect(() => props.onVisibleChange(visible()))
createEffect(() => { createEffect(() => {
const ctx = props.serverCtx const ctx = props.serverCtx()
const value = session() const value = session()
if (!ctx || !value || prefetched) return if (!ctx || !value || prefetched) return
prefetched = true prefetched = true
@@ -157,7 +157,7 @@ function SessionTabEntry(props: {
index={props.index} index={props.index}
active={props.active} active={props.active}
forceTruncate={props.forceTruncate} forceTruncate={props.forceTruncate}
session={session()} session={session}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)} fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onRename={rename} onRename={rename}
onNavigate={props.onNavigate} onNavigate={props.onNavigate}
@@ -170,8 +170,8 @@ function SessionTabEntry(props: {
function DraftTabSlot(props: { function DraftTabSlot(props: {
tab: Extract<Tab, { type: "draft" }> tab: Extract<Tab, { type: "draft" }>
id: string id: string
index: number index: () => number
active: boolean active: () => boolean
title: string title: string
onNavigate: (element: HTMLDivElement) => void onNavigate: (element: HTMLDivElement) => void
onClose: () => void onClose: () => void
@@ -181,7 +181,7 @@ function DraftTabSlot(props: {
return props.id return props.id
}, },
get index() { get index() {
return props.index return props.index()
}, },
}) })
let ref!: HTMLDivElement let ref!: HTMLDivElement
@@ -191,7 +191,7 @@ function DraftTabSlot(props: {
ref={sortable.ref} ref={sortable.ref}
data-titlebar-tab-slot data-titlebar-tab-slot
data-tab-key={props.id} data-tab-key={props.id}
data-active={props.active} data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink" class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
> >
<DraftTabItem <DraftTabItem
@@ -202,7 +202,7 @@ function DraftTabSlot(props: {
title={props.title} title={props.title}
onNavigate={() => props.onNavigate(ref)} onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose} onClose={props.onClose}
active={props.active} active={props.active()}
dragging={sortable.isDragSource()} dragging={sortable.isDragSource()}
/> />
</div> </div>
@@ -211,7 +211,7 @@ function DraftTabSlot(props: {
export function TitlebarTabStrip(props: { export function TitlebarTabStrip(props: {
tabs: Tab[] tabs: Tab[]
currentTab: Tab | undefined currentTab: () => Tab | undefined
forceTruncate: boolean forceTruncate: boolean
onNavigate: (tab: Tab, el?: HTMLDivElement) => void onNavigate: (tab: Tab, el?: HTMLDivElement) => void
onClose: (tab: Tab) => void onClose: (tab: Tab) => void
@@ -248,7 +248,7 @@ export function TitlebarTabStrip(props: {
]) ])
function selectAdjacentTab(offset: -1 | 1) { function selectAdjacentTab(offset: -1 | 1) {
const current = props.currentTab const current = props.currentTab()
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset) const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
const next = props.tabs.find((tab) => tabKey(tab) === key) const next = props.tabs.find((tab) => tabKey(tab) === key)
if (next) props.onNavigate(next) if (next) props.onNavigate(next)
@@ -350,10 +350,10 @@ export function TitlebarTabStrip(props: {
<SessionTabEntry <SessionTabEntry
tab={tab} tab={tab}
id={id} id={id}
index={visibleIndex()} index={visibleIndex}
active={props.currentTab === tab} active={() => props.currentTab() === tab}
forceTruncate={props.forceTruncate} forceTruncate={props.forceTruncate}
serverCtx={serverCtx()} serverCtx={serverCtx}
onVisibleChange={(visible) => setVisibility(id, visible)} onVisibleChange={(visible) => setVisibility(id, visible)}
onNavigate={(element) => { onNavigate={(element) => {
ref = element ref = element
@@ -368,8 +368,8 @@ export function TitlebarTabStrip(props: {
<DraftTabSlot <DraftTabSlot
tab={tab} tab={tab}
id={id} id={id}
index={visibleIndex()} index={visibleIndex}
active={props.currentTab === tab} active={() => props.currentTab() === tab}
title={language.t("command.session.new")} title={language.t("command.session.new")}
onNavigate={(element) => { onNavigate={(element) => {
ref = element ref = element
+12 -10
View File
@@ -46,8 +46,8 @@ const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px
const macTrafficLightsBaseWidth = 84 const macTrafficLightsBaseWidth = 84
export type TitlebarUpdate = { export type TitlebarUpdate = {
version: string | undefined version: () => string | undefined
installing: boolean installing: () => boolean
install: () => void install: () => void
} }
@@ -121,8 +121,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const hasProjects = createMemo(() => layout.projects.list().length > 0) const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true)) const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
const updateState = createMemo<TitlebarUpdatePillState>(() => { const updateState = createMemo<TitlebarUpdatePillState>(() => {
const installing = props.update?.installing ?? false const installing = props.update?.installing() ?? false
const version = props.update?.version const version = props.update?.version()
return { return {
visible: version !== undefined || installing, visible: version !== undefined || installing,
installing, installing,
@@ -392,7 +392,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
<TitlebarTabStrip <TitlebarTabStrip
tabs={tabsStore} tabs={tabsStore}
currentTab={currentTab()} currentTab={currentTab}
forceTruncate={tabsAreOverflowing()} forceTruncate={tabsAreOverflowing()}
onOverflowChange={setTabsAreOverflowing} onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => { onNavigate={(tab, el) => {
@@ -657,10 +657,12 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
} }
return ( return (
<Show when={["local", "beta", "dev"].includes(channel)}> <>
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono"> {["local", "beta", "dev"].includes(channel) && (
{channel.toUpperCase()} <div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
</div> {channel.toUpperCase()}
</Show> </div>
)}
</>
) )
} }
@@ -1,4 +1,4 @@
import { For, Show, type JSX } from "solid-js" import { Show, type JSX } from "solid-js"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -50,20 +50,7 @@ export function WindowsAppMenu(props: {
return ( return (
<DropdownMenu gutter={4} modal={false} placement="bottom-start"> <DropdownMenu gutter={4} modal={false} placement="bottom-start">
<Show {props.variant === "v2" ? (
when={props.variant === "v2"}
fallback={
<DropdownMenu.Trigger
as={IconButton}
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md shrink-0"
aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
}
>
<div <div
data-component="desktop-icon-button" data-component="desktop-icon-button"
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1" class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
@@ -78,31 +65,39 @@ export function WindowsAppMenu(props: {
onKeyDown={rememberFocus} onKeyDown={rememberFocus}
/> />
</div> </div>
</Show> ) : (
<DropdownMenu.Trigger
as={IconButton}
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md shrink-0"
aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
)}
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content class="desktop-app-menu"> <DropdownMenu.Content class="desktop-app-menu">
<DropdownMenu.Group> <DropdownMenu.Group>
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel> <DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
<For each={DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows"))}> {DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
{(menu) => ( <DesktopMenuSubmenu label={language.t(menu.labelKey)}>
<DesktopMenuSubmenu label={language.t(menu.labelKey)}> {menu.items
<For each={menu.items?.filter((entry) => desktopMenuVisible(entry, "windows"))}> ?.filter((entry) => desktopMenuVisible(entry, "windows"))
{(entry) => { .map((entry) =>
// Static menu data: an early return keeps the union narrowing a Show fallback would lose. entry.type === "separator" ? (
if (entry.type === "separator") return <DropdownMenu.Separator /> <DropdownMenu.Separator />
return ( ) : (
<DesktopMenuItem <DesktopMenuItem
label={entry.labelKey ? language.t(entry.labelKey) : ""} label={entry.labelKey ? language.t(entry.labelKey) : ""}
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows} keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
disabled={entry.command ? commandDisabled(entry.command) : false} disabled={entry.command ? commandDisabled(entry.command) : false}
onSelect={() => runEntry(entry)} onSelect={() => runEntry(entry)}
/> />
) ),
}} )}
</For> </DesktopMenuSubmenu>
</DesktopMenuSubmenu> ))}
)}
</For>
</DropdownMenu.Group> </DropdownMenu.Group>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
@@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
const refresh: string[] = [] const refresh: string[] = []
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "filesystem.changed", type: "file.watcher.updated",
properties: { properties: {
file: "src/new.ts", file: "src/new.ts",
event: "add", event: "add",
@@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "filesystem.changed", type: "file.watcher.updated",
properties: { properties: {
file: "src/open.ts", file: "src/open.ts",
event: "change", event: "change",
@@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "filesystem.changed", type: "file.watcher.updated",
properties: { properties: {
file: "src", file: "src",
event: "change", event: "change",
@@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "filesystem.changed", type: "file.watcher.updated",
properties: { properties: {
file: "src/file.ts", file: "src/file.ts",
event: "change", event: "change",
@@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "filesystem.changed", type: "file.watcher.updated",
properties: { properties: {
file: ".git/index.lock", file: ".git/index.lock",
event: "change", event: "change",
+1 -1
View File
@@ -16,7 +16,7 @@ type WatcherOps = {
} }
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "filesystem.changed") return if (event.type !== "file.watcher.updated") return
const props = const props =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = typeof props?.file === "string" ? props.file : undefined const rawPath = typeof props?.file === "string" ? props.file : undefined
+114 -130
View File
@@ -6,8 +6,6 @@ import type {
CommandInfo, CommandInfo,
CommandListInput, CommandListInput,
CommandListOutput, CommandListOutput,
IntegrationListInput,
IntegrationListOutput,
LocationGetInput, LocationGetInput,
LocationGetOutput, LocationGetOutput,
PermissionRequest, PermissionRequest,
@@ -26,9 +24,9 @@ import { getFilename } from "@opencode-ai/core/util/path"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/core/util/retry"
import { batch } from "solid-js" import { batch } from "solid-js"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State } from "./types" import type { State, VcsCache } from "./types"
import type { ServerSession } from "../server-session" import type { ServerSession } from "../server-session"
import { cmp, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils" import { cmp, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query" import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
@@ -223,10 +221,6 @@ type CommandListApi = {
readonly list: (input?: CommandListInput) => Promise<CommandListOutput> readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
} }
type IntegrationListApi = {
readonly list: (input?: IntegrationListInput) => Promise<IntegrationListOutput>
}
type ReferenceListApi = { type ReferenceListApi = {
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput> readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
} }
@@ -237,13 +231,6 @@ export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: Agen
queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))), queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
}) })
export const loadIntegrationsQuery = (scope: ServerScope, directory: string | null, sdk: IntegrationListApi) =>
queryOptions({
queryKey: [scope, directory, "integrations"] as const,
queryFn: () =>
retry(() => sdk.list(directory ? { location: { directory } } : undefined).then((result) => result.data)),
})
export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> => export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> =>
retry(() => api.list({ location: { directory } }).then((result) => result.data)) retry(() => api.list({ location: { directory } }).then((result) => result.data))
@@ -285,6 +272,7 @@ export async function bootstrapDirectory(input: {
} }
store: Store<State> store: Store<State>
setStore: SetStoreFunction<State> setStore: SetStoreFunction<State>
vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void loadSessions: (directory: string) => Promise<void> | void
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
global: { global: {
@@ -309,109 +297,104 @@ export async function bootstrapDirectory(input: {
const revKey = ScopedKey.from(input.scope, input.directory) const revKey = ScopedKey.from(input.scope, input.directory)
const rev = (providerRev.get(revKey) ?? 0) + 1 const rev = (providerRev.get(revKey) ?? 0) + 1
providerRev.set(revKey, rev) providerRev.set(revKey, rev)
const slow = [ ;(async () => {
() => Promise.resolve(input.loadSessions(input.directory)), const slow = [
() => () => Promise.resolve(input.loadSessions(input.directory)),
input.queryClient () =>
.ensureQueryData(loadAgentsQuery(input.scope, directoryKey(input.directory), input.api.agent))
.then((data) => input.setStore("agent", data)),
!seededProject &&
(() =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
input.setStore("project", project.id),
)),
!seededPath &&
(() =>
input.queryClient input.queryClient
.ensureQueryData(loadPathQuery(input.scope, directoryKey(input.directory), input.api.location)) .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
.then((data) => { .then((data) => input.setStore("agent", data)),
const next = projectID(data.directory ?? input.directory, input.global.project) !seededProject &&
if (next) input.setStore("project", next) (() =>
})), retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
input.mcp && input.setStore("project", project.id),
(() => loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))), )),
() => !seededPath &&
input.queryClient.fetchQuery( (() =>
loadReferencesQuery(input.scope, directoryKey(input.directory), input.api.reference), input.queryClient
), .ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location))
() => .then((data) => {
retry(() => const next = projectID(data.directory ?? input.directory, input.global.project)
input.api.permission.request if (next) input.setStore("project", next)
.list({ location: { directory: input.directory } }) })),
.then((result) => result.data) input.mcp &&
.then((permissions) => { (() =>
const ids = permissions.map((permission) => permission.sessionID) loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
const grouped = groupBySession( () => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.api.reference)),
permissions.filter((permission) => !!permission.id && !!permission.sessionID), () =>
) retry(() =>
const warm = input.session input.api.permission.request
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) .list({ location: { directory: input.directory } })
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) .then((result) => result.data)
return warm.then(() => .then((permissions) => {
batch(() => { const ids = permissions.map((permission) => permission.sessionID)
const current = input.session?.data.permission ?? input.store.permission const grouped = groupBySession(
for (const sessionID of Object.keys(current)) { permissions.filter((permission) => !!permission.id && !!permission.sessionID),
if (grouped[sessionID]) continue )
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue const warm = input.session
if (input.session) input.session.set("permission", sessionID, []) ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
if (!input.session) input.setStore("permission", sessionID, []) : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
} return warm.then(() =>
for (const [sessionID, permissions] of Object.entries(grouped)) { batch(() => {
const value = reconcile( const current = input.session?.data.permission ?? input.store.permission
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), for (const sessionID of Object.keys(current)) {
{ key: "id" }, if (grouped[sessionID]) continue
) if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("permission", sessionID, value) if (input.session) input.session.set("permission", sessionID, [])
if (!input.session) input.setStore("permission", sessionID, value) if (!input.session) input.setStore("permission", sessionID, [])
} }
}), for (const [sessionID, permissions] of Object.entries(grouped)) {
) const value = reconcile(
}), permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
), { key: "id" },
() => )
retry(() => if (input.session) input.session.set("permission", sessionID, value)
input.api.question.request if (!input.session) input.setStore("permission", sessionID, value)
.list({ location: { directory: input.directory } }) }
.then((result) => result.data) }),
.then((questions) => { )
const ids = questions.map((question) => question.sessionID) }),
const grouped = groupBySession( ),
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[], () =>
) retry(() =>
const warm = input.session input.api.question.request
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) .list({ location: { directory: input.directory } })
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) .then((result) => result.data)
return warm.then(() => .then((questions) => {
batch(() => { const ids = questions.map((question) => question.sessionID)
const current = input.session?.data.question ?? input.store.question const grouped = groupBySession(
for (const sessionID of Object.keys(current)) { questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
if (grouped[sessionID]) continue )
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue const warm = input.session
if (input.session) input.session.set("question", sessionID, []) ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
if (!input.session) input.setStore("question", sessionID, []) : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
} return warm.then(() =>
for (const [sessionID, questions] of Object.entries(grouped)) { batch(() => {
const value = reconcile( const current = input.session?.data.question ?? input.store.question
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), for (const sessionID of Object.keys(current)) {
{ key: "id" }, if (grouped[sessionID]) continue
) if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, value) if (input.session) input.session.set("question", sessionID, [])
if (!input.session) input.setStore("question", sessionID, value) if (!input.session) input.setStore("question", sessionID, [])
} }
}), for (const [sessionID, questions] of Object.entries(grouped)) {
) const value = reconcile(
}), questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
), { key: "id" },
() => Promise.resolve(input.loadSessions(input.directory)), )
input.mcp && if (input.session) input.session.set("question", sessionID, value)
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))), if (!input.session) input.setStore("question", sessionID, value)
input.mcp && }
(() => }),
input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, directoryKey(input.directory), input.api.mcp))), )
() => }),
input.queryClient ),
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api)) () => Promise.resolve(input.loadSessions(input.directory)),
.catch((err) => { input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))),
input.mcp &&
(() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))),
() =>
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api)).catch((err) => {
const project = getFilename(input.directory) const project = getFilename(input.directory)
showToast({ showToast({
variant: "error", variant: "error",
@@ -419,19 +402,20 @@ export async function bootstrapDirectory(input: {
description: formatServerError(err, input.translate), description: formatServerError(err, input.translate),
}) })
}), }),
].filter(Boolean) as (() => Promise<any>)[] ].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint() await waitForPaint()
const slowErrs = errors(await runAll(slow)) const slowErrs = errors(await runAll(slow))
if (slowErrs.length > 0) { if (slowErrs.length > 0) {
console.error("Failed to finish bootstrap instance", slowErrs[0]) console.error("Failed to finish bootstrap instance", slowErrs[0])
const project = getFilename(input.directory) const project = getFilename(input.directory)
showToast({ showToast({
variant: "error", variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }), title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(slowErrs[0], input.translate), description: formatServerError(slowErrs[0], input.translate),
}) })
} }
if (loading && slowErrs.length === 0) input.setStore("status", "complete") if (loading && slowErrs.length === 0) input.setStore("status", "complete")
})()
} }
@@ -8,7 +8,6 @@ import { ServerScope } from "@/utils/server-scope"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager let createChildStoreManager: typeof import("./child-store").createChildStoreManager
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = [] const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
let providerQuerySuccess = true
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [ const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
store[0], store[0],
store[1], store[1],
@@ -59,17 +58,11 @@ beforeAll(async () => {
get isLoading() { get isLoading() {
return options().queryKey?.[1] === "path" return options().queryKey?.[1] === "path"
}, },
get isSuccess() {
return options().queryKey?.[1] === "providers" && options().enabled === true && providerQuerySuccess
},
get isRefetchError() {
return false
},
get data() { get data() {
if (options().queryKey?.[1] === "path") throw new Error("pending path data read") if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
if (options().queryKey?.[1] === "lsp") return [] if (options().queryKey?.[1] === "lsp") return []
if (options().queryKey?.[1] === "providers") return providerQuerySuccess ? provider : undefined if (options().queryKey?.[1] === "providers") return provider
return undefined return undefined
}, },
} }
@@ -90,7 +83,6 @@ describe("createChildStoreManager", () => {
const manager = createChildStoreManager({ const manager = createChildStoreManager({
owner, owner,
connected: () => true,
scope: ServerScope.local, scope: ServerScope.local,
persist, persist,
isBooting: () => false, isBooting: () => false,
@@ -122,7 +114,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => { const dispose = createOwner((owner) => {
manager = createChildStoreManager({ manager = createChildStoreManager({
owner, owner,
connected: () => true,
scope: ServerScope.local, scope: ServerScope.local,
persist, persist,
isBooting: () => false, isBooting: () => false,
@@ -157,7 +148,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => { const dispose = createOwner((owner) => {
manager = createChildStoreManager({ manager = createChildStoreManager({
owner, owner,
connected: () => true,
scope: ServerScope.local, scope: ServerScope.local,
persist, persist,
isBooting: () => false, isBooting: () => false,
@@ -183,37 +173,6 @@ describe("createChildStoreManager", () => {
} }
}) })
test("writes refreshed VCS data to the child store", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store] = manager.child("/project", { bootstrap: false })
manager.vcs("/project", { branch: "feature", default_branch: "main" })
expect(store.vcs).toEqual({ branch: "feature", default_branch: "main" })
} finally {
dispose()
}
})
test("enables MCP only when requested for the directory", () => { test("enables MCP only when requested for the directory", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined let manager: ReturnType<typeof createChildStoreManager> | undefined
const offset = querySingles.length const offset = querySingles.length
@@ -222,7 +181,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => { const dispose = createOwner((owner) => {
manager = createChildStoreManager({ manager = createChildStoreManager({
owner, owner,
connected: () => true,
scope: ServerScope.local, scope: ServerScope.local,
persist, persist,
isBooting: () => false, isBooting: () => false,
@@ -272,7 +230,6 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => { const dispose = createOwner((owner) => {
manager = createChildStoreManager({ manager = createChildStoreManager({
owner, owner,
connected: () => true,
scope: ServerScope.local, scope: ServerScope.local,
persist, persist,
isBooting: () => false, isBooting: () => false,
@@ -316,71 +273,4 @@ describe("createChildStoreManager", () => {
dispose() dispose()
} }
}) })
test("does not mark a cancelled provider query as ready", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
providerQuerySuccess = false
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => true,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store] = manager.child("/cancelled")
expect(store.provider_ready).toBe(false)
} finally {
providerQuerySuccess = true
dispose()
}
})
test("does not enable location queries before the event handshake", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
let connected = false
const offset = querySingles.length
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
connected: () => connected,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
manager.child("/handshake")
const queries = querySingles.slice(offset)
expect(queries[0]?.().enabled).toBe(false)
expect(queries[4]?.().enabled).toBe(false)
connected = true
expect(queries[0]?.().enabled).toBe(true)
expect(queries[4]?.().enabled).toBe(true)
} finally {
dispose()
}
})
}) })
@@ -1,4 +1,4 @@
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js" import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@/types" import type { VcsInfo } from "@/types"
@@ -22,7 +22,6 @@ import type { ServerScope } from "@/utils/server-scope"
export function createChildStoreManager(input: { export function createChildStoreManager(input: {
owner: Owner owner: Owner
connected: Accessor<boolean>
scope: ServerScope scope: ServerScope
persist: typeof persisted persist: typeof persisted
isBooting: (directory: string) => boolean isBooting: (directory: string) => boolean
@@ -189,29 +188,17 @@ export function createChildStoreManager(input: {
const [mcpEnabled, setMcpEnabled] = createSignal(false) const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false) const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
const pathQuery = useQuery(() => ({ const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
...input.queryOptions.path(key), const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
enabled: input.connected() && instanceQueriesEnabled(), const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
})) const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
const mcpQuery = useQuery(() => ({
...input.queryOptions.mcp(key),
enabled: input.connected() && mcpEnabled(),
}))
const mcpResourceQuery = useQuery(() => ({
...input.queryOptions.mcpResources(key),
enabled: input.connected() && mcpEnabled(),
}))
const lspQuery = useQuery(() => ({
...input.queryOptions.lsp(key),
enabled: input.connected() && instanceQueriesEnabled(),
}))
const providerQuery = useQuery(() => ({ const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key), ...input.queryOptions.providers(key),
enabled: input.connected() && instanceQueriesEnabled(), enabled: instanceQueriesEnabled(),
})) }))
const referenceQuery = useQuery(() => ({ const referenceQuery = useQuery(() => ({
...input.queryOptions.references(key), ...input.queryOptions.references(key),
enabled: input.connected() && instanceQueriesEnabled(), enabled: instanceQueriesEnabled(),
})) }))
const child = createStore<State>({ const child = createStore<State>({
@@ -219,14 +206,13 @@ export function createChildStoreManager(input: {
projectMeta: initialMeta, projectMeta: initialMeta,
icon: initialIcon, icon: initialIcon,
get provider_ready() { get provider_ready() {
return instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError) return instanceQueriesEnabled() && !providerQuery.isLoading
}, },
get provider() { get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} } const EMPTY = { all: new Map(), connected: [], default: {} }
if (!providerQuery.isSuccess && !providerQuery.isRefetchError) return EMPTY if (providerQuery.isLoading) return EMPTY
const provider = providerQuery.data if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
if (provider.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider return providerQuery.data ?? EMPTY
return provider
}, },
config: {}, config: {},
get path() { get path() {
@@ -387,15 +373,6 @@ export function createChildStoreManager(input: {
setStore("icon", value) setStore("icon", value)
} }
function vcs(directory: string, value: VcsInfo) {
const key = directoryKey(directory)
const child = ensureChild(directory)
const cached = vcsCache.get(key)
if (!cached) return
cached.setStore("value", value)
child[1]("vcs", value)
}
return { return {
children, children,
ensureChild, ensureChild,
@@ -403,7 +380,6 @@ export function createChildStoreManager(input: {
peek, peek,
projectMeta, projectMeta,
projectIcon, projectIcon,
vcs,
mark, mark,
pin, pin,
unpin, unpin,
@@ -120,7 +120,7 @@ describe("applyGlobalEvent", () => {
expect(refreshCount).toBe(1) expect(refreshCount).toBe(1)
}) })
test("leaves server.connected refresh to the connection sync", () => { test("handles server.connected by triggering refresh", () => {
let refreshCount = 0 let refreshCount = 0
applyGlobalEvent({ applyGlobalEvent({
event: { type: "server.connected" }, event: { type: "server.connected" },
@@ -131,7 +131,7 @@ describe("applyGlobalEvent", () => {
setGlobalProject() {}, setGlobalProject() {},
}) })
expect(refreshCount).toBe(0) expect(refreshCount).toBe(1)
}) })
}) })
@@ -1,5 +1,4 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { Message, Part, Project, Todo } from "@/types" import type { Message, Part, Project, Todo } from "@/types"
import type { import type {
@@ -38,10 +37,11 @@ export function applyGlobalEvent(input: {
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
refresh: () => void refresh: () => void
}) { }) {
if (input.event.type === "global.disposed") { if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
input.refresh() input.refresh()
return return
} }
if (input.event.type !== "project.updated") return if (input.event.type !== "project.updated") return
const properties = input.event.properties as Project const properties = input.event.properties as Project
const result = Binary.search(input.project, properties.id, (s) => s.id) const result = Binary.search(input.project, properties.id, (s) => s.id)
@@ -187,18 +187,6 @@ export function applyDirectoryEvent(input: {
input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break break
} }
case "project.directory.resolved": {
const properties = event.properties as { projectID: string; directory: string; previous: string }
input.store.session.forEach((session, index) => {
const adopted = ProjectDirectories.adopt(
{ projectID: session.projectID, directory: session.location.directory },
properties,
)
if (!adopted) return
input.setStore("session", index, (current) => ({ ...current, ...adopted }))
})
break
}
case "session.renamed": { case "session.renamed": {
const properties = event.properties as { sessionID: string; title: string } const properties = event.properties as { sessionID: string; title: 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)
@@ -1,6 +1,6 @@
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, Todo } from "@/types"
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise" 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"
@@ -34,7 +34,6 @@ describe("app session cache", () => {
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined> permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined> question: Record<string, QuestionRequest[] | undefined>
form: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined> part_text_accum_delta: Record<string, string | undefined>
} = { } = {
session_status: { ses_1: { type: "busy" } as SessionStatus }, session_status: { ses_1: { type: "busy" } as SessionStatus },
@@ -45,7 +44,6 @@ describe("app session cache", () => {
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] }, permission: { ses_1: [] as PermissionRequest[] },
question: { ses_1: [] as QuestionRequest[] }, question: { ses_1: [] as QuestionRequest[] },
form: { ses_1: [] as FormInfo[] },
part_text_accum_delta: { prt_1: "streamed text" }, part_text_accum_delta: { prt_1: "streamed text" },
} }
@@ -59,7 +57,6 @@ describe("app session cache", () => {
expect(store.session_status.ses_1).toBeUndefined() expect(store.session_status.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined() expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined() expect(store.question.ses_1).toBeUndefined()
expect(store.form.ses_1).toBeUndefined()
}) })
test("dropSessionCaches clears message-backed parts", () => { test("dropSessionCaches clears message-backed parts", () => {
@@ -73,7 +70,6 @@ describe("app session cache", () => {
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined> permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined> question: Record<string, QuestionRequest[] | undefined>
form: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined> part_text_accum_delta: Record<string, string | undefined>
} = { } = {
session_status: {}, session_status: {},
@@ -84,7 +80,6 @@ describe("app session cache", () => {
part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {}, permission: {},
question: {}, question: {},
form: {},
part_text_accum_delta: {}, part_text_accum_delta: {},
} }
@@ -1,5 +1,5 @@
import type { Message, Part, Todo } from "@/types" import type { Message, Part, Todo } from "@/types"
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise" 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"
@@ -14,7 +14,6 @@ type SessionCache = {
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined> permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined> question: Record<string, QuestionRequest[] | undefined>
form?: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined> part_text_accum_delta: Record<string, string | undefined>
} }
@@ -39,7 +38,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
delete store.session_status[sessionID] delete store.session_status[sessionID]
delete store.permission[sessionID] delete store.permission[sessionID]
delete store.question[sessionID] delete store.question[sessionID]
if (store.form) delete store.form[sessionID]
} }
} }
+11
View File
@@ -7,6 +7,7 @@ import { useServerHealth } from "@/utils/server-health"
import { createServerSdkContext } from "./server-sdk" import { createServerSdkContext } from "./server-sdk"
import { createServerSyncContext } from "./server-sync" import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web" import { getOwner } from "solid-js/web"
import { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
@@ -97,6 +98,15 @@ function createServerCtx(
scope: ServerScope, scope: ServerScope,
projects: ReturnType<typeof createServerProjects>, projects: ReturnType<typeof createServerProjects>,
) { ) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnReconnect: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
})
const sdk = createServerSdkContext(conn, scope) const sdk = createServerSdkContext(conn, scope)
const sync = createServerSyncContext(sdk) const sync = createServerSyncContext(sdk)
@@ -131,6 +141,7 @@ function createServerCtx(
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url)) (conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
return { return {
queryClient,
sdk, sdk,
sync, sync,
isLocal, isLocal,
-2
View File
@@ -33,8 +33,6 @@ type PluralKey =
| "session.question.pending" | "session.question.pending"
| "session.followupDock.summary" | "session.followupDock.summary"
| "session.revertDock.summary" | "session.revertDock.summary"
| "session.background.shell"
| "session.background.subagent"
type Source = { dict: Record<string, string> } type Source = { dict: Record<string, string> }
function cookie(locale: Locale) { function cookie(locale: Locale) {
+3 -3
View File
@@ -1,4 +1,4 @@
import { createMemo, createResource } from "solid-js" import { type Accessor, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda" import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
@@ -25,8 +25,8 @@ function modelKey(model: ModelKey) {
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({ export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
name: "Models", name: "Models",
gate: false, gate: false,
init: (props: { directory?: string } = {}) => { init: (props: { directory?: Accessor<string | undefined> } = {}) => {
const providers = useProviders(() => props.directory) const providers = useProviders(() => props.directory?.())
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("model", ["model.v1"]), Persist.global("model", ["model.v1"]),
+4 -9
View File
@@ -398,20 +398,15 @@ function createServerNotificationState(input: {
const unsub = serverSDK().event.listen((e) => { const unsub = serverSDK().event.listen((e) => {
const event = e.details const event = e.details
if ( if (event.type !== "session.idle" && event.type !== "session.execution.failed") return
event.type !== "session.execution.succeeded" &&
event.type !== "session.execution.interrupted" &&
event.type !== "session.execution.failed"
)
return
const directory = e.name const directory = e.name
const time = Date.now() const time = Date.now()
if (event.type === "session.execution.failed") { if (event.type === "session.idle") {
handleSessionError(directory, event, time) handleSessionIdle(directory, event, time)
return return
} }
handleSessionIdle(directory, event, time) handleSessionError(directory, event, time)
}) })
onCleanup(() => { onCleanup(() => {
meta.disposed = true meta.disposed = true
+5 -2
View File
@@ -6,9 +6,12 @@ export type { DirectorySDK } from "./server-sdk"
const context = createSimpleContext({ const context = createSimpleContext({
name: "SDK", name: "SDK",
// Resolves the directory-scoped SDK reactively from the (possibly changing) server. // Resolves the directory-scoped SDK reactively from the (possibly changing) server.
init: (props: { directory: string }) => { init: (props: { directory: string | Accessor<string> }) => {
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
return createMemo(() => serverSDK().ensureDirSdkContext(props.directory)) return createMemo(() => {
const directory = typeof props.directory === "function" ? props.directory() : props.directory
return serverSDK().ensureDirSdkContext(directory)
})
}, },
}) })
+53 -134
View File
@@ -4,7 +4,6 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js" import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createApiForServer, type ServerApi } from "@/utils/server" import { createApiForServer, type ServerApi } from "@/utils/server"
import { useLanguage } from "./language" import { useLanguage } from "./language"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
@@ -13,6 +12,10 @@ import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global" import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent } export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
type QueuedServerEvent = { directory: string; payload: ServerEvent } type QueuedServerEvent = { directory: string; payload: ServerEvent }
type CurrentDelta = Extract< type CurrentDelta = Extract<
@@ -92,20 +95,15 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
} }
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>> type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
type ServerSDKBase = { type ServerSDKBase = {
server: ServerConnection.Any server: ServerConnection.Any
scope: ServerScope scope: ServerScope
url: string url: string
api: ServerApi api: ServerApi
connection: {
status: Accessor<ServerConnectionStatus>
attempt: Accessor<number>
error: Accessor<string | undefined>
}
event: { event: {
on: ServerEventEmitter["on"] on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"] listen: ServerEventEmitter["listen"]
start: () => Promise<void> | undefined
} }
} }
@@ -132,15 +130,14 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
type Queued = QueuedServerEvent type Queued = QueuedServerEvent
const FLUSH_FRAME_MS = 16 const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8 const STREAM_YIELD_MS = 8
const CONNECT_TIMEOUT_MS = 2_000 const RECONNECT_DELAY_MS = 250
const RECONNECT_DELAY_MS = 1_000
let queue: Queued[] = [] let queue: Queued[] = []
let buffer: Queued[] = [] let buffer: Queued[] = []
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
let last = 0 let last = 0
function flush() { const flush = () => {
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer)
timer = undefined timer = undefined
@@ -160,132 +157,63 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
buffer.length = 0 buffer.length = 0
} }
function schedule() { const schedule = () => {
if (timer) return if (timer) return
const elapsed = Date.now() - last const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed)) timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
} }
function publish(event: OpenCodeEvent) { let streamErrorLogged = false
const directory = event.location?.directory ?? "global" const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
}
function wait(delay: number, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal.removeEventListener("abort", done)
resolve()
}
})
}
let attempt: AbortController | undefined let attempt: AbortController | undefined
let run: Promise<void> | undefined let run: Promise<void> | undefined
let started = false let started = false
let generation = 0 let generation = 0
const [connection, setConnection] = createStore<{
status: ServerConnectionStatus
attempt: number
error?: string
}>({ status: "connecting", attempt: 0 })
async function connect(signal: AbortSignal): Promise<{ error: unknown; connectedAt: number | undefined }> { const start = () => {
let connectedAt: number | undefined
// Bound the initial handshake and tie this request to the stream lifetime.
const request = new AbortController()
const cancel = () => request.abort(signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), CONNECT_TIMEOUT_MS)
signal.addEventListener("abort", cancel, { once: true })
try {
// Open the event stream and validate its initial handshake.
const iterator = eventApi.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const first = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (first.done) {
const error =
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
return { error, connectedAt }
}
if (first.value.type !== "server.connected")
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
// Publish the connected state before forwarding live events.
clearTimeout(timeout)
publish(first.value)
connectedAt = Date.now()
setConnection({ status: "connected", attempt: 0, error: undefined })
// Forward events until the stream closes or this connection is cancelled.
let yielded = Date.now()
while (!signal.aborted) {
const event = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
publish(event.value)
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0, signal)
}
return { error: undefined, connectedAt }
} catch (error) {
return { error, connectedAt }
} finally {
request.abort()
clearTimeout(timeout)
signal.removeEventListener("abort", cancel)
}
}
async function runStream(active: number) {
let retries = 0
// oxlint-disable-next-line no-unmodified-loop-condition -- stop() changes the lifecycle flags and aborts the active request
while (!abort.signal.aborted && started && generation === active) {
setConnection({ status: retries === 0 ? "connecting" : "reconnecting", attempt: retries, error: undefined })
const controller = new AbortController()
attempt = controller
const onAbort = () => controller.abort()
abort.signal.addEventListener("abort", onAbort)
const result = await connect(controller.signal)
abort.signal.removeEventListener("abort", onAbort)
if (abort.signal.aborted || !started || generation !== active) {
if (attempt === controller) attempt = undefined
return
}
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) retries = 0
retries += 1
const message =
result.error === undefined
? undefined
: result.error instanceof Error
? result.error.message
: String(result.error)
console.info("[global-sdk] event stream disconnected", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
attempt: retries,
error: message,
})
setConnection({ status: "reconnecting", attempt: retries, error: message })
await wait(RECONNECT_DELAY_MS, controller.signal)
if (attempt === controller) attempt = undefined
}
}
function start() {
if (started) return run if (started) return run
started = true started = true
const active = ++generation const active = ++generation
const previous = run const previous = run
const current = (async () => { const current = (async () => {
if (previous) await previous if (previous) await previous
await runStream(active) // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started && generation === active) {
attempt = new AbortController()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = eventApi.event.subscribe({ signal: attempt.signal })
let yielded = Date.now()
for await (const event of events) {
streamErrorLogged = false
const directory = event.location?.directory ?? "global"
const payload = adaptServerEvent(event)
if (enqueueServerEvent(queue, { directory, payload })) schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
}
if (abort.signal.aborted || !started || generation !== active) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => { })().finally(() => {
if (run !== current) return if (run !== current) return
run = undefined run = undefined
@@ -295,7 +223,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
return run return run
} }
function stop() { const stop = () => {
started = false started = false
generation++ generation++
attempt?.abort() attempt?.abort()
@@ -304,17 +232,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
onMount(() => { onMount(() => {
makeEventListener(window, "pagehide", stop) makeEventListener(window, "pagehide", stop)
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start)) makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
void start()
}) })
onCleanup(() => { onCleanup(() => {
stop() stop()
abort.abort() abort.abort()
if (timer) clearTimeout(timer) flush()
timer = undefined
queue = []
buffer = []
emitter.clear()
}) })
const api = createApiForServer({ server: server.http, fetch: platform.fetch }) const api = createApiForServer({ server: server.http, fetch: platform.fetch })
@@ -324,14 +247,10 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
scope, scope,
url: server.http.url, url: server.http.url,
api, api,
connection: {
status: () => connection.status,
attempt: () => connection.attempt,
error: () => connection.error,
},
event: { event: {
on: emitter.on.bind(emitter), on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter), listen: emitter.listen.bind(emitter),
start,
}, },
} }
} }
@@ -351,13 +270,13 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
name: "ServerSDK", name: "ServerSDK",
// Returns an accessor so the resolved server can change reactively (e.g. a // Returns an accessor so the resolved server can change reactively (e.g. a
// /new-session draft retargeting its server) without re-instantiating the subtree. // /new-session draft retargeting its server) without re-instantiating the subtree.
init: (props: { server?: ServerConnection.Any }) => { init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
const global = useGlobal() const global = useGlobal()
const language = useLanguage() const language = useLanguage()
const server = useServer() const server = useServer()
return createMemo<ServerSDK>(() => { return createMemo<ServerSDK>(() => {
const conn = props.server ?? server.current const conn = props.server?.() ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable")) if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
return global.ensureServerCtx(conn).sdk return global.ensureServerCtx(conn).sdk
}) })
@@ -18,18 +18,18 @@ describe("v2 session reducer", () => {
apply({ apply({
...base, ...base,
id: "evt_admitted", id: "evt_admitted",
type: "session.inbox.enqueued", type: "session.input.admitted",
data: { data: {
sessionID: "ses_1", sessionID: "ses_1",
inboxID: "msg_user", inputID: "msg_user",
item: { type: "user", delivery: "steer", payload: { text: "hello" } }, input: { type: "user", delivery: "steer", data: { text: "hello" } },
}, },
}) })
apply({ apply({
...base, ...base,
id: "evt_promoted", id: "evt_promoted",
type: "session.inbox.delivered", type: "session.input.promoted",
data: { sessionID: "ses_1", inboxID: "msg_user" }, data: { sessionID: "ses_1", inputID: "msg_user" },
}) })
apply({ apply({
...base, ...base,
@@ -203,66 +203,14 @@ describe("v2 session reducer", () => {
event({ event({
...base, ...base,
id: "evt_promoted", id: "evt_promoted",
type: "session.inbox.delivered", type: "session.input.promoted",
data: { sessionID: "ses_1", inboxID: "msg_user" }, data: { sessionID: "ses_1", inputID: "msg_user" },
}), }),
) )
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] }) expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
}) })
test("projects rendered instruction updates", () => {
const reducer = createV2SessionReducer()
const result = reducer.reduce(
[],
event({
...base,
id: "evt_instructions",
type: "session.instructions.updated",
data: { sessionID: "ses_1", delta: { agents: "hash" }, text: "Changed instructions" },
}),
)
expect(result?.messages).toEqual([
{
id: "msg_instructions",
type: "system",
text: "Changed instructions",
description: "Instructions updated: agents",
metadata: undefined,
time: { created: 1 },
},
])
})
test("projects session movement with the previous location", () => {
const result = createV2SessionReducer().reduce(
[],
event({
...base,
id: "evt_moved",
type: "session.moved",
data: {
sessionID: "ses_1",
projectID: "project_2",
location: { directory: "/repo-2" },
subpath: "packages/app",
},
}),
{ projectID: "project_1", location: { directory: "/repo-1" } },
)
expect(result?.messages).toMatchObject([
{
id: "msg_moved",
type: "location-switched",
projectID: "project_2",
location: { directory: "/repo-2" },
previous: { projectID: "project_1", location: { directory: "/repo-1" } },
},
])
})
test("removes cancelled input from the pending promotion fold", () => { test("removes cancelled input from the pending promotion fold", () => {
const reducer = createV2SessionReducer() const reducer = createV2SessionReducer()
reducer.reduce( reducer.reduce(
@@ -270,11 +218,11 @@ describe("v2 session reducer", () => {
event({ event({
...base, ...base,
id: "evt_admitted", id: "evt_admitted",
type: "session.inbox.enqueued", type: "session.input.admitted",
data: { data: {
sessionID: "ses_1", sessionID: "ses_1",
inboxID: "msg_user", inputID: "msg_user",
item: { type: "user", delivery: "queue", payload: { text: "cancel me" } }, input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
}, },
}), }),
) )
@@ -283,8 +231,8 @@ describe("v2 session reducer", () => {
event({ event({
...base, ...base,
id: "evt_cancelled", id: "evt_cancelled",
type: "session.inbox.cancelled", type: "session.input.cancelled",
data: { sessionID: "ses_1", inboxID: "msg_user" }, data: { sessionID: "ses_1", inputID: "msg_user" },
}), }),
) )
const result = reducer.reduce( const result = reducer.reduce(
@@ -292,8 +240,8 @@ describe("v2 session reducer", () => {
event({ event({
...base, ...base,
id: "evt_promoted", id: "evt_promoted",
type: "session.inbox.delivered", type: "session.input.promoted",
data: { sessionID: "ses_1", inboxID: "msg_user" }, data: { sessionID: "ses_1", inputID: "msg_user" },
}), }),
) )
@@ -307,11 +255,11 @@ describe("v2 session reducer", () => {
event({ event({
...base, ...base,
id: "evt_admitted", id: "evt_admitted",
type: "session.inbox.enqueued", type: "session.input.admitted",
data: { data: {
sessionID: "ses_1", sessionID: "ses_1",
inboxID: "msg_user", inputID: "msg_user",
item: { type: "user", delivery: "queue", payload: { text: "steer me" } }, input: { type: "user", delivery: "queue", data: { text: "steer me" } },
}, },
}), }),
) )
@@ -320,8 +268,8 @@ describe("v2 session reducer", () => {
event({ event({
...base, ...base,
id: "evt_steered", id: "evt_steered",
type: "session.inbox.delivery.changed", type: "session.input.steered",
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "steer" }, data: { sessionID: "ses_1", inputID: "msg_user" },
}), }),
) )
reducer.reduce( reducer.reduce(
@@ -329,8 +277,8 @@ describe("v2 session reducer", () => {
event({ event({
...base, ...base,
id: "evt_queued", id: "evt_queued",
type: "session.inbox.delivery.changed", type: "session.input.queued",
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "queue" }, data: { sessionID: "ses_1", inputID: "msg_user" },
}), }),
) )
@@ -339,8 +287,8 @@ describe("v2 session reducer", () => {
event({ event({
...base, ...base,
id: "evt_promoted", id: "evt_promoted",
type: "session.inbox.delivered", type: "session.input.promoted",
data: { sessionID: "ses_1", inboxID: "msg_user" }, data: { sessionID: "ses_1", inputID: "msg_user" },
}), }),
) )
@@ -1,4 +1,4 @@
import type { OpenCodeEvent, SessionInboxItem, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise" import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise"
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }> type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }> type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
@@ -8,18 +8,13 @@ export type V2SessionReduction = {
sessionID: string sessionID: string
messages: SessionMessageInfo[] messages: SessionMessageInfo[]
touched: string[] touched: string[]
removed?: string[]
missing?: string missing?: string
} }
export function createV2SessionReducer() { export function createV2SessionReducer() {
const pending = new Map<string, SessionInboxItem>() const pending = new Map<string, SessionPendingMessage>()
const reduce = ( const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => {
source: readonly SessionMessageInfo[],
event: OpenCodeEvent,
session?: Pick<SessionInfo, "location" | "projectID" | "subpath">,
): V2SessionReduction | undefined => {
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
const sessionID = event.data.sessionID const sessionID = event.data.sessionID
const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({ const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({
@@ -31,59 +26,32 @@ export function createV2SessionReducer() {
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id]) result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
switch (event.type) { switch (event.type) {
case "session.inbox.enqueued": case "session.input.admitted":
pending.set(key(sessionID, event.data.inboxID), event.data.item) pending.set(key(sessionID, event.data.inputID), event.data.input)
if (event.data.item.type === "user") return result([...source])
return append({ case "session.input.cancelled":
id: event.data.inboxID, pending.delete(key(sessionID, event.data.inputID))
type: "user", return
metadata: event.data.item.payload.metadata, case "session.input.promoted": {
text: event.data.item.payload.text, const input = pending.get(key(sessionID, event.data.inputID))
files: event.data.item.payload.files, pending.delete(key(sessionID, event.data.inputID))
agents: event.data.item.payload.agents, if (!input) return { ...result([...source]), missing: event.data.inputID }
time: { created: event.created },
})
if (event.data.item.type !== "synthetic") return result([...source])
return append({
id: event.data.inboxID,
type: "synthetic",
metadata: event.data.item.payload.metadata,
text: event.data.item.payload.text,
description: event.data.item.payload.description,
time: { created: event.created },
})
case "session.inbox.cancelled":
pending.delete(key(sessionID, event.data.inboxID))
return {
...result(source.filter((item) => item.id !== event.data.inboxID)),
removed: source.some((item) => item.id === event.data.inboxID) ? [event.data.inboxID] : [],
}
case "session.inbox.delivered": {
const input = pending.get(key(sessionID, event.data.inboxID))
pending.delete(key(sessionID, event.data.inboxID))
const existing = source.find((item) => item.id === event.data.inboxID)
if (existing) {
const promoted = { ...existing, time: { ...existing.time, created: event.created } }
return result([...source.filter((item) => item.id !== existing.id), promoted], [existing.id])
}
if (!input) return { ...result([...source]), missing: event.data.inboxID }
if (input.type === "user") if (input.type === "user")
return append({ return append({
id: event.data.inboxID, id: event.data.inputID,
type: "user", type: "user",
metadata: input.payload.metadata, metadata: input.data.metadata,
text: input.payload.text, text: input.data.text,
files: input.payload.files, files: input.data.files,
agents: input.payload.agents, agents: input.data.agents,
time: { created: event.created }, time: { created: event.created },
}) })
if (input.type !== "synthetic") return result([...source])
return append({ return append({
id: event.data.inboxID, id: event.data.inputID,
type: "synthetic", type: "synthetic",
metadata: input.payload.metadata, metadata: input.data.metadata,
text: input.payload.text, text: input.data.text,
description: input.payload.description, description: input.data.description,
time: { created: event.created }, time: { created: event.created },
}) })
} }
@@ -115,32 +83,6 @@ export function createV2SessionReducer() {
)?.model, )?.model,
time: { created: event.created }, time: { created: event.created },
}) })
case "session.moved":
if (!session) return
return append({
id: messageID(event.id),
type: "location-switched",
metadata: event.metadata,
location: event.data.location,
projectID: event.data.projectID,
subpath: event.data.subpath,
previous: {
location: session.location,
projectID: session.projectID,
subpath: session.subpath,
},
time: { created: event.created },
})
case "session.instructions.updated":
if (event.data.text === undefined) return
return append({
id: messageID(event.id),
type: "system",
text: event.data.text,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata,
time: { created: event.created },
})
case "session.synthetic": case "session.synthetic":
return append({ return append({
id: messageID(event.id), id: messageID(event.id),
@@ -409,7 +351,7 @@ export function createV2SessionReducer() {
metadata: event.metadata, metadata: event.metadata,
reason: event.data.reason, reason: event.data.reason,
summary: "", summary: "",
recent: event.data.recent ?? "", recent: event.data.recent,
time: { created: event.created }, time: { created: event.created },
}) })
case "session.compaction.delta": case "session.compaction.delta":
@@ -460,11 +402,8 @@ export function createV2SessionReducer() {
type: "compaction", type: "compaction",
status: "failed", status: "failed",
metadata: current?.metadata ?? event.metadata, metadata: current?.metadata ?? event.metadata,
reason: event.data.reason ?? "manual", reason: event.data.reason,
error: event.data.error ?? { error: event.data.error,
type: "compaction.failed",
message: "Compaction failed before recording an error",
},
time: current?.time ?? { created: event.created }, time: current?.time ?? { created: event.created },
} }
if (!current) return append(failed) if (!current) return append(failed)
@@ -1,10 +1,8 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { retry } from "@opencode-ai/core/util/retry" import type { retry } from "@opencode-ai/core/util/retry"
import type { import type {
FormInfo,
OpenCodeEvent, OpenCodeEvent,
SessionApi, SessionApi,
SessionInboxInfo,
SessionInfo, SessionInfo,
SessionMessageAssistant, SessionMessageAssistant,
SessionMessageAssistantTool, SessionMessageAssistantTool,
@@ -350,187 +348,6 @@ describe("server session", () => {
expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }]) expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
}) })
test("projects V2 pending inputs and forms", () => {
const ctx = setup({ child: session("child") })
const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent)
apply({
id: "evt_admitted",
created: 1,
type: "session.inbox.enqueued",
data: {
sessionID: "child",
inboxID: "msg_input",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
})
apply({
id: "evt_form",
created: 2,
type: "form.created",
data: { form: { id: "frm_1", sessionID: "child", title: "Choose", fields: [] } },
})
expect(ctx.store.data.pending.child).toMatchObject([{ id: "msg_input", delivery: "steer" }])
expect(ctx.store.data.input.child).toEqual(["msg_input"])
expect(ctx.store.data.form.child).toMatchObject([{ id: "frm_1", title: "Choose" }])
expect(ctx.store.data.message.child).toMatchObject([{ id: "msg_input" }])
apply({
id: "evt_cancelled",
created: 3,
type: "session.inbox.cancelled",
data: { sessionID: "child", inboxID: "msg_input" },
})
apply({
id: "evt_form_done",
created: 4,
type: "form.cancelled",
data: { sessionID: "child", id: "frm_1" },
})
expect(ctx.store.data.pending.child).toEqual([])
expect(ctx.store.data.input.child).toEqual([])
expect(ctx.store.data.form.child).toEqual([])
expect(ctx.store.data.message.child).toEqual([])
})
test("does not let transient hydration overwrite newer events", async () => {
const ctx = setup({ child: session("child") })
const response = Promise.withResolvers<{ pending: SessionInboxInfo[]; forms: FormInfo[] }>()
const form: FormInfo = {
id: "frm_1",
sessionID: "child",
title: "Choose",
fields: [{ key: "choice", type: "string" as const, title: "Choice" }],
}
let loads = 0
const hydration = ctx.store.hydrateTransient("child", () => {
loads += 1
if (loads === 1) return response.promise
return Promise.resolve({ pending: ctx.store.data.pending.child ?? [], forms: [form] })
})
ctx.store.applyV2({
id: "evt_admitted",
created: 1,
type: "session.inbox.enqueued",
data: {
sessionID: "child",
inboxID: "msg_input",
item: { type: "user", delivery: "queue", payload: { text: "new" } },
},
} as OpenCodeEvent)
response.resolve({
pending: [],
forms: [form],
})
await hydration
expect(ctx.store.data.pending.child).toMatchObject([{ id: "msg_input" }])
expect(ctx.store.data.form.child).toMatchObject([{ id: "frm_1" }])
expect(loads).toBe(2)
})
test("removes only the compaction input named by the start event", () => {
const ctx = setup({ child: session("child") })
const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent)
apply({
id: "evt_compaction_inbox",
created: 1,
type: "session.inbox.enqueued",
data: {
sessionID: "child",
inboxID: "msg_compaction",
item: { type: "compaction", delivery: "queue", payload: { reason: "manual" } },
},
})
expect(ctx.store.data.input.child).toBeUndefined()
expect(ctx.store.data.pending.child).toHaveLength(1)
apply({
id: "evt_compaction_inbox_2",
created: 2,
type: "session.inbox.enqueued",
data: {
sessionID: "child",
inboxID: "msg_compaction_2",
item: { type: "compaction", delivery: "queue", payload: { reason: "manual" } },
},
})
apply({
id: "evt_compaction_ended",
created: 3,
type: "session.compaction.ended",
data: { sessionID: "child", reason: "manual", text: "summary", recent: "recent" },
})
expect(ctx.store.data.pending.child).toHaveLength(2)
apply({
id: "evt_compaction_started",
created: 4,
type: "session.compaction.started",
data: { sessionID: "child", inputID: "msg_compaction", reason: "manual" },
})
expect(ctx.store.data.pending.child?.map((item) => item.id)).toEqual(["msg_compaction_2"])
})
test("projects committed revert before server reconciliation", () => {
const ctx = setup({ child: session("child") })
ctx.store.remember({ ...session("child"), revert: { messageID: "msg_2", partID: "prt_1" } })
ctx.store.set("input", "child", ["msg_1", "msg_2"])
ctx.store.set("session_message", "child", [
{ id: "msg_1", type: "user", text: "keep", time: { created: 1 } },
{ id: "msg_2", type: "user", text: "remove", time: { created: 2 } },
])
ctx.store.applyV2({
id: "evt_revert",
created: 3,
type: "session.revert.committed",
data: { sessionID: "child", to: "msg_2" },
} as OpenCodeEvent)
expect(ctx.store.data.info.child?.revert).toBeUndefined()
expect(ctx.store.data.input.child).toEqual(["msg_1"])
expect(ctx.store.data.session_message.child?.map((message) => message.id)).toEqual(["msg_1"])
})
test("does not restore a message hydrated before a committed revert", async () => {
const response = Promise.withResolvers<SessionMessageInfo>()
const store = createServerSession({
session: {
get: async () => session("child"),
message: () => response.promise,
} as unknown as SessionApi,
message: {
list: async () => currentPage({ data: [], response: { headers: new Headers() } }),
} as unknown as MessageApi,
})
store.remember(session("child"))
store.applyV2({
id: "evt_delivered",
created: 1,
type: "session.inbox.delivered",
data: { sessionID: "child", inboxID: "msg_2" },
} as OpenCodeEvent)
store.applyV2({
id: "evt_revert",
created: 2,
type: "session.revert.committed",
data: { sessionID: "child", to: "msg_2" },
} as OpenCodeEvent)
response.resolve({ id: "msg_2", type: "user", text: "stale", time: { created: 1 } })
await response.promise
await Bun.sleep(0)
expect(store.data.session_message.child ?? []).toEqual([])
})
test("resolves lineage by session ID without directory", async () => { test("resolves lineage by session ID without directory", async () => {
const ctx = setup({ child: session("child", "root"), root: session("root") }) const ctx = setup({ child: session("child", "root"), root: session("root") })
@@ -551,17 +368,6 @@ describe("server session", () => {
expect(ctx.store.data.message.root).toEqual([]) expect(ctx.store.data.message.root).toEqual([])
}) })
test("reloads cached sessions after reconnect invalidation", async () => {
const ctx = setup({ root: session("root") })
await ctx.store.sync("root")
ctx.store.invalidate()
await ctx.store.sync("root")
expect(ctx.get).toHaveLength(2)
expect(ctx.messages).toHaveLength(2)
})
test("loads current session content through the current message API", async () => { test("loads current session content through the current message API", async () => {
const requests: unknown[] = [] const requests: unknown[] = []
const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } } const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } }
+8 -154
View File
@@ -1,14 +1,6 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/core/util/retry"
import type { import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
FormInfo,
OpenCodeEvent,
SessionApi,
SessionInfo,
SessionInboxInfo,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import type { Message, Part, Todo } from "@/types" import type { Message, Part, Todo } from "@/types"
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise" import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import { batch } from "solid-js" import { batch } from "solid-js"
@@ -199,9 +191,6 @@ export function createServerSession(
todo: {} as Record<string, Todo[]>, todo: {} as Record<string, Todo[]>,
permission: {} as Record<string, PermissionRequest[]>, permission: {} as Record<string, PermissionRequest[]>,
question: {} as Record<string, QuestionRequest[]>, question: {} as Record<string, QuestionRequest[]>,
form: {} as Record<string, FormInfo[]>,
pending: {} as Record<string, SessionInboxInfo[]>,
input: {} as Record<string, string[]>,
message: {} as Record<string, Message[]>, message: {} as Record<string, Message[]>,
session_message: {} as Record<string, SessionMessageInfo[]>, session_message: {} as Record<string, SessionMessageInfo[]>,
part: {} as Record<string, Part[]>, part: {} as Record<string, Part[]>,
@@ -215,11 +204,6 @@ export function createServerSession(
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>>()
const v2 = createV2SessionReducer() const v2 = createV2SessionReducer()
const pendingRevision = new Map<string, number>()
const formRevision = new Map<string, number>()
const messageHydrationRevision = new Map<string, number>()
const invalidated = new Set<string>()
let invalidationRevision = 0
const messageLoads = new Map<string, MessageLoadState>() const messageLoads = new Map<string, MessageLoadState>()
const pendingParts = new Map<string, Map<string, Set<string>>>() const pendingParts = new Map<string, Map<string, Set<string>>>()
const orphanParts = new Map<string, Set<string>>() const orphanParts = new Map<string, Set<string>>()
@@ -278,9 +262,6 @@ export function createServerSession(
...Object.entries(data.question) ...Object.entries(data.question)
.filter(([, items]) => items.length > 0) .filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID), .map(([sessionID]) => sessionID),
...Object.entries(data.form)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.session_status) ...Object.entries(data.session_status)
.filter(([, status]) => status.type !== "idle") .filter(([, status]) => status.type !== "idle")
.map(([sessionID]) => sessionID), .map(([sessionID]) => sessionID),
@@ -482,7 +463,6 @@ export function createServerSession(
if (evicted.has(item.sessionID)) deltaBases.delete(partID) if (evicted.has(item.sessionID)) deltaBases.delete(partID)
} }
sessionIDs.forEach((sessionID) => { sessionIDs.forEach((sessionID) => {
messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1)
generations.delete(sessionID) generations.delete(sessionID)
clearOptimistic(sessionID) clearOptimistic(sessionID)
requests.delete(sessionID) requests.delete(sessionID)
@@ -526,9 +506,6 @@ export function createServerSession(
...Object.entries(data.question) ...Object.entries(data.question)
.filter(([, items]) => items.length > 0) .filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID), .map(([sessionID]) => sessionID),
...Object.entries(data.form)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.session_status) ...Object.entries(data.session_status)
.filter(([, status]) => status.type !== "idle") .filter(([, status]) => status.type !== "idle")
.map(([sessionID]) => sessionID), .map(([sessionID]) => sessionID),
@@ -820,16 +797,13 @@ export function createServerSession(
touch(sessionID) touch(sessionID)
return runInflight(inflight, sessionID, async () => { return runInflight(inflight, sessionID, async () => {
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
const invalid = invalidated.has(sessionID) if (cached && data.info[sessionID] && !options?.force) return
const revision = invalidationRevision
if (cached && data.info[sessionID] && !invalid && !options?.force) return
await Promise.all([ await Promise.all([
resolve(sessionID, invalid ? { ...options, force: true } : options), resolve(sessionID, options),
cached && !invalid && !options?.force cached && !options?.force
? Promise.resolve() ? Promise.resolve()
: loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize), : loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize),
]) ])
if (invalid && invalidationRevision === revision) invalidated.delete(sessionID)
}) })
} }
@@ -869,7 +843,7 @@ export function createServerSession(
const projectV2 = (reduction: V2SessionReduction) => { const projectV2 = (reduction: V2SessionReduction) => {
reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID)) reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID))
setData("session_message", reduction.sessionID, reconcile(reduction.messages)) setData("session_message", reduction.sessionID, reconcile(reduction.messages))
if (reduction.touched.length === 0 && !reduction.removed?.length) return if (reduction.touched.length === 0) return
const touched = new Set(reduction.touched) const touched = new Set(reduction.touched)
let parentID: string | undefined let parentID: string | undefined
@@ -886,9 +860,6 @@ export function createServerSession(
const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages) const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages)
batch(() => { batch(() => {
for (const messageID of reduction.removed ?? []) {
apply({ type: "message.removed", properties: { sessionID: reduction.sessionID, messageID } })
}
for (const message of normalized.messages) { for (const message of normalized.messages) {
if (!touched.has(message.id)) continue if (!touched.has(message.id)) continue
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } }) apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
@@ -912,14 +883,9 @@ export function createServerSession(
const hydrateV2Message = (sessionID: string, messageID: string) => { const hydrateV2Message = (sessionID: string, messageID: string) => {
if (!sessionApi) return if (!sessionApi) return
const active = generation(sessionID)
const revision = messageHydrationRevision.get(sessionID) ?? 0
void sessionApi void sessionApi
.message({ sessionID, messageID }) .message({ sessionID, messageID })
.then((message) => { .then((message) => {
if (generations.get(sessionID) !== active) return
if ((messageHydrationRevision.get(sessionID) ?? 0) !== revision) return
if (removedMessages.get(sessionID)?.has(message.id)) return
const current = data.session_message[sessionID] ?? [] const current = data.session_message[sessionID] ?? []
const messages = [...current.filter((item) => item.id !== message.id), message].sort(compareMessages) const messages = [...current.filter((item) => item.id !== message.id), message].sort(compareMessages)
projectV2({ sessionID, messages, touched: [message.id] }) projectV2({ sessionID, messages, touched: [message.id] })
@@ -928,86 +894,21 @@ export function createServerSession(
} }
const applyV2 = (event: OpenCodeEvent) => { const applyV2 = (event: OpenCodeEvent) => {
if (event.type === "form.created") {
formRevision.set(event.data.form.sessionID, (formRevision.get(event.data.form.sessionID) ?? 0) + 1)
const current = data.form[event.data.form.sessionID] ?? []
if (!current.some((form) => form.id === event.data.form.id))
setData("form", event.data.form.sessionID, [...current, event.data.form])
return
}
if (event.type === "form.replied" || event.type === "form.cancelled") {
formRevision.set(event.data.sessionID, (formRevision.get(event.data.sessionID) ?? 0) + 1)
setData("form", event.data.sessionID, (forms) => forms?.filter((form) => form.id !== event.data.id))
return
}
if (event.type === "project.directory.resolved") {
Object.values(data.info).forEach((info) => {
if (!info) return
const adopted = ProjectDirectories.adopt(
{ projectID: info.projectID, directory: info.location.directory },
event.data,
)
if (adopted) remember({ ...info, ...adopted })
})
return
}
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
const sessionID = event.data.sessionID const sessionID = event.data.sessionID
if ( const reduction = v2.reduce(data.session_message[sessionID] ?? [], event)
event.type === "session.inbox.enqueued" ||
event.type === "session.inbox.delivery.changed" ||
event.type === "session.inbox.cancelled" ||
event.type === "session.inbox.delivered" ||
event.type === "session.compaction.started" ||
event.type === "session.compaction.failed"
)
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
if (event.type === "session.inbox.enqueued") {
const current = data.pending[sessionID] ?? []
if (!current.some((item) => item.id === event.data.inboxID))
setData("pending", sessionID, [
...current,
{ id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item },
])
if (event.data.item.type !== "compaction" && !data.input[sessionID]?.includes(event.data.inboxID))
setData("input", sessionID, [...(data.input[sessionID] ?? []), event.data.inboxID])
}
if (event.type === "session.inbox.delivery.changed")
setData("pending", sessionID, (items) =>
items?.map((item) => (item.id === event.data.inboxID ? { ...item, delivery: event.data.delivery } : item)),
)
if (event.type === "session.inbox.cancelled" || event.type === "session.inbox.delivered") {
setData("pending", sessionID, (items) => items?.filter((item) => item.id !== event.data.inboxID))
setData("input", sessionID, (items) => items?.filter((id) => id !== event.data.inboxID))
}
if (event.type === "session.compaction.started" || event.type === "session.compaction.failed") {
setData("pending", sessionID, (items) => items?.filter((item) => item.id !== event.data.inputID))
setData("input", sessionID, (items) => items?.filter((id) => id !== event.data.inputID))
}
const info = data.info[sessionID]
const reduction = v2.reduce(data.session_message[sessionID] ?? [], event, info)
if (reduction) { if (reduction) {
projectV2(reduction) projectV2(reduction)
if (reduction.missing) hydrateV2Message(sessionID, reduction.missing) if (reduction.missing) hydrateV2Message(sessionID, reduction.missing)
} }
if (event.type === "session.agent.selected" && info) remember({ ...info, agent: event.data.agent }) const info = data.info[sessionID]
if (event.type === "session.model.selected") {
if (info) remember({ ...info, model: event.data.model })
if (data.session_message[sessionID]) hydrateV2Message(sessionID, event.id.replace(/^evt_/, "msg_"))
}
if (event.type === "session.renamed" && info) if (event.type === "session.renamed" && info)
remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } }) remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } })
if (event.type === "session.renamed" && !info)
void resolve(sessionID)
.then((current) =>
remember({ ...current, title: event.data.title, time: { ...current.time, updated: event.created } }),
)
.catch(() => undefined)
if (event.type === "session.moved" && info) if (event.type === "session.moved" && info)
remember({ remember({
...info, ...info,
projectID: event.data.projectID, projectID: event.data.projectID ?? info.projectID,
location: event.data.location, location: event.data.location,
subpath: event.data.subpath, subpath: event.data.subpath,
time: { ...info.time, updated: event.created }, time: { ...info.time, updated: event.created },
@@ -1033,29 +934,12 @@ export function createServerSession(
next: event.data.at, next: event.data.at,
}) })
if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {}) if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {})
if (event.type === "session.revert.staged" && info) remember({ ...info, revert: event.data.revert })
if (event.type === "session.revert.cleared" && info) remember({ ...info, revert: undefined })
if (event.type === "session.revert.committed") {
messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1)
if (info) remember({ ...info, revert: undefined })
setData("input", sessionID, (items) => items?.filter((id) => id < event.data.to))
const source = data.session_message[sessionID] ?? []
const removed = source.filter((message) => message.id >= event.data.to).map((message) => message.id)
removedMessages.set(sessionID, new Set([...(removedMessages.get(sessionID) ?? []), ...removed]))
projectV2({
sessionID,
messages: source.filter((message) => message.id < event.data.to),
touched: [],
removed,
})
}
if ( if (
event.type === "session.revert.staged" || event.type === "session.revert.staged" ||
event.type === "session.revert.cleared" || event.type === "session.revert.cleared" ||
event.type === "session.revert.committed" event.type === "session.revert.committed"
) )
void resolve(sessionID, { force: true }).catch(() => {}) void resolve(sessionID, { force: true }).catch(() => {})
if (event.type === "session.revert.committed") void sync(sessionID, { force: true }).catch(() => {})
} }
const apply = (event: { type: string; properties?: unknown }) => { const apply = (event: { type: string; properties?: unknown }) => {
@@ -1384,36 +1268,6 @@ export function createServerSession(
}, },
}, },
sync, sync,
async hydrateTransient(sessionID: string, load: () => Promise<{ pending: SessionInboxInfo[]; forms: FormInfo[] }>) {
while (true) {
const pendingAt = pendingRevision.get(sessionID) ?? 0
const formAt = formRevision.get(sessionID) ?? 0
const result = await load()
const pendingStable = (pendingRevision.get(sessionID) ?? 0) === pendingAt
const formStable = (formRevision.get(sessionID) ?? 0) === formAt
if (pendingStable) {
setData("pending", sessionID, reconcile(result.pending))
setData(
"input",
sessionID,
reconcile(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
)
}
if (formStable) setData("form", sessionID, reconcile(result.forms))
if (pendingStable && formStable) return
}
},
refreshPinned(hydrateTransient: (sessionID: string) => Promise<void>) {
return Promise.all(
[...pinned.keys()].flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
).then(() => undefined)
},
invalidate() {
invalidationRevision += 1
Object.keys(data.info).forEach((sessionID) => invalidated.add(sessionID))
Object.keys(data.message).forEach((sessionID) => invalidated.add(sessionID))
setMeta("at", {})
},
prefetch, prefetch,
shouldPrefetch(sessionID: string, limit: number) { shouldPrefetch(sessionID: string, limit: number) {
if (data.message[sessionID] === undefined) return true if (data.message[sessionID] === undefined) return true
+1 -18
View File
@@ -9,13 +9,7 @@ import type {
import { QueryClient } from "@tanstack/solid-query" import { QueryClient } from "@tanstack/solid-query"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load" import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
import { import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
loadActiveSessionsQuery,
loadMcpQuery,
loadMcpResourcesQuery,
reconcileActiveSessionStatuses,
seedActiveSessionStatuses,
} from "./server-sync"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session" import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
@@ -105,17 +99,6 @@ describe("active session query", () => {
next: 10, next: 10,
}) })
}) })
test("replaces stale active statuses after reconnect", () => {
const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"])
session.set("session_status", "ses_stale", { type: "busy" })
session.set("session_status", "ses_active", { type: "idle" })
reconcileActiveSessionStatuses(session, { ses_active: { type: "running" } })
expect(session.data.session_status.ses_stale).toEqual({ type: "idle" })
expect(session.data.session_status.ses_active).toEqual({ type: "busy" })
})
}) })
describe("pickDirectoriesToEvict", () => { describe("pickDirectoriesToEvict", () => {
+112 -221
View File
@@ -1,11 +1,11 @@
import type { Config, Path, Project, ProviderAuthResponse } from "@/types" import type { Config, Path, Project, ProviderAuthResponse } 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"
import { batch, createMemo, getOwner, onCleanup, untrack } from "solid-js" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error" import type { InitError } from "../pages/error"
import { type ServerEvent, type ServerSDK } from "./server-sdk" import { ServerSDK } from "./server-sdk"
import { import {
bootstrapDirectory, bootstrapDirectory,
bootstrapGlobal, bootstrapGlobal,
@@ -13,7 +13,6 @@ import {
loadAgentsQuery, loadAgentsQuery,
loadCommands, loadCommands,
loadGlobalConfigQuery, loadGlobalConfigQuery,
loadIntegrationsQuery,
loadPathQuery, loadPathQuery,
loadProjectsQuery, loadProjectsQuery,
loadProvidersQuery, loadProvidersQuery,
@@ -30,7 +29,7 @@ import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from
import type { SolidQueryOptions } from "@tanstack/solid-query" import type { SolidQueryOptions } from "@tanstack/solid-query"
import { createRefreshQueue } from "./global-sync/queue" import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils" import { directoryKey } from "./global-sync/utils"
import { pathKey, PathKey } from "@/utils/path-key" import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync" import { createDirSyncContext } from "./directory-sync"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
@@ -54,8 +53,6 @@ import type {
} 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"
import { createCatalogSync } from "./server-sync/catalog"
import { createConnectionSync } from "./server-sync/connection"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
type GlobalStore = { type GlobalStore = {
@@ -69,16 +66,6 @@ type GlobalStore = {
reload: undefined | "pending" | "complete" reload: undefined | "pending" | "complete"
} }
const SESSION_LIST_EVENTS = new Set([
"session.created",
"session.updated",
"session.deleted",
"session.moved",
"session.forked",
"session.renamed",
"session.usage.updated",
])
type McpListApi = { type McpListApi = {
readonly list: (input?: McpListInput) => Promise<McpListOutput> readonly list: (input?: McpListInput) => Promise<McpListOutput>
} }
@@ -172,22 +159,11 @@ export function seedActiveSessionStatuses(
} }
} }
export function reconcileActiveSessionStatuses(
session: Pick<ServerSession, "data" | "set">,
active: SessionActiveOutput,
) {
Object.keys(session.data.session_status)
.filter((sessionID) => active[sessionID] === undefined && session.data.session_status[sessionID]?.type !== "idle")
.forEach((sessionID) => session.set("session_status", sessionID, { type: "idle" }))
Object.keys(active).forEach((sessionID) => session.set("session_status", sessionID, { type: "busy" }))
}
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) { function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
return { return {
globalConfig: () => loadGlobalConfigQuery(scope), globalConfig: () => loadGlobalConfigQuery(scope),
projects: () => loadProjectsQuery(scope, serverAPI.project), projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI), providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
integrations: (directory: PathKey | null) => loadIntegrationsQuery(scope, directory, serverAPI.integration),
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location), path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent), agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference), references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
@@ -208,46 +184,29 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const booting = new Map<string, Promise<void>>() const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>() const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>() const sessionMeta = new Map<string, { limit: number }>()
const sessionRevision = new Map<string, number>()
const session = createServerSession(serverSDK.api.session, serverSDK.api.message) const session = createServerSession(serverSDK.api.session, serverSDK.api.message)
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api) const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
const connected = () => serverSDK.connection.status() === "connected"
const hydrateSessionState = async (sessionID: string) => {
await session.hydrateTransient(sessionID, async () => {
const [pending, forms] = await Promise.all([
serverSDK.api.session.inbox.list({ sessionID }),
serverSDK.api.form.list({ sessionID }),
])
return { pending, forms }
})
}
const hydrateSession = (sessionID: string) => Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [ queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
{ ...queryOptionsApi.globalConfig(), enabled: connected() },
{ ...queryOptionsApi.providers(null), enabled: connected() },
{ ...queryOptionsApi.path(null), enabled: connected() },
],
})) }))
const activeSessionsQuery = useQuery(() => ({ const activeSessionsQuery = useQuery(() =>
...loadActiveSessionsQuery(serverSDK.scope, { loadActiveSessionsQuery(serverSDK.scope, {
active: async () => { active: async () => {
const active = await serverSDK.api.session.active() const active = await serverSDK.api.session.active()
reconcileActiveSessionStatuses(session, active) seedActiveSessionStatuses(session, active)
Object.keys(active).forEach((sessionID) => { for (const sessionID of Object.keys(active)) {
void Promise.all([session.resolve(sessionID), hydrateSessionState(sessionID)]).catch(() => undefined) void session.resolve(sessionID).catch(() => undefined)
}) }
return active return active
}, },
}), }),
enabled: connected(), )
}))
const [globalStore, setGlobalStore] = createStore<GlobalStore>({ const [globalStore, setGlobalStore] = createStore<GlobalStore>({
get ready() { get ready() {
return bootstrap.isSuccess return !bootstrap.isPending
}, },
project: [], project: [],
provider_auth: {}, provider_auth: {},
@@ -272,6 +231,21 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server)) const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
const refreshProviders = () =>
queryClient.refetchQueries({
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
})
let bootedAt = 0
let bootingRoot = false
let eventFrame: number | undefined
let eventTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
if (eventTimer !== undefined) clearTimeout(eventTimer)
})
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
setGlobalStore("project", next) setGlobalStore("project", next)
} }
@@ -296,9 +270,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
setGlobalStore: setBootStore, setGlobalStore: setBootStore,
queryClient, queryClient,
}) })
return Date.now() bootedAt = Date.now()
return bootedAt
}, },
enabled: connected(),
})) }))
const set = ((...input: unknown[]) => { const set = ((...input: unknown[]) => {
@@ -320,13 +294,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const children = createChildStoreManager({ const children = createChildStoreManager({
owner, owner,
connected,
scope: serverSDK.scope, scope: serverSDK.scope,
persist: persisted, persist: persisted,
isBooting: (directory) => booting.has(directory), isBooting: (directory) => booting.has(directory),
isLoadingSessions: (directory) => sessionLoads.has(directory), isLoadingSessions: (directory) => sessionLoads.has(directory),
onBootstrap: (directory) => { onBootstrap: (directory) => {
if (!connected()) return
void bootstrapInstance(directory) void bootstrapInstance(directory)
}, },
onMcp: (directory, setStore) => { onMcp: (directory, setStore) => {
@@ -349,60 +321,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
translate: language.t, translate: language.t,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { global: {
get provider() { provider: globalStore.provider,
return globalStore.provider
},
}, },
}) })
const catalog = createCatalogSync({
scope: serverSDK.scope,
queryClient,
active: () => Object.keys(children.children).filter(children.active).map(pathKey),
load: (directory) =>
Promise.all([
queryClient.fetchQuery(queryOptionsApi.providers(directory)),
queryClient.fetchQuery(queryOptionsApi.integrations(directory)),
]).then(() => undefined),
})
const refreshVcs = (directory: string) =>
serverSDK.api.vcs
.get({ location: { directory } })
.then((result) =>
children.vcs(directory, {
branch: result.data.branch.current,
default_branch: result.data.branch.default,
}),
)
.catch(() => undefined)
const connection = createConnectionSync({
status: serverSDK.connection.status,
invalidate: () => {
session.invalidate()
void queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === serverSDK.scope,
refetchType: "none",
})
},
connected: (info) => {
if (info.reconnect) void session.refreshPinned(hydrateSessionState).catch(() => undefined)
if (activeSessionsQuery.data !== undefined && !activeSessionsQuery.isFetching) void activeSessionsQuery.refetch()
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
Object.keys(children.children)
.filter(children.active)
.forEach((directory) => {
queue.push(directory)
if (children.children[directory]?.[0].status !== "loading") void refreshVcs(directory)
})
},
})
async function loadCurrentSessions(directory: string, key: PathKey, limit: number) {
while (true) {
const revision = sessionRevision.get(key) ?? 0
const result = await loadRootSessions({ api: serverSDK.api.session, directory, limit })
if ((sessionRevision.get(key) ?? 0) === revision) return result
}
}
async function loadSessions(directory: string, options?: { limit?: number }) { async function loadSessions(directory: string, options?: { limit?: number }) {
const key = directoryKey(directory) const key = directoryKey(directory)
@@ -433,7 +354,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
.fetchQuery({ .fetchQuery({
...queryOptionsApi.sessions(key), ...queryOptionsApi.sessions(key),
queryFn: () => queryFn: () =>
loadCurrentSessions(directory, key, limit) loadRootSessions({ api: serverSDK.api.session, directory, limit })
.then((x) => { .then((x) => {
const nonArchived = (x.data ?? []) const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id) .filter((s) => !!s?.id)
@@ -489,28 +410,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
children.pin(key) children.pin(key)
const promise = Promise.resolve().then(async () => { const promise = Promise.resolve().then(async () => {
const child = children.ensureChild(directory) const child = children.ensureChild(directory)
const initial = child[0].status === "loading" const cache = children.vcsCache.get(key)
await Promise.all([ if (!cache) return
bootstrapDirectory({ await bootstrapDirectory({
directory, directory,
scope: serverSDK.scope, scope: serverSDK.scope,
mcp: children.mcp(key), mcp: children.mcp(key),
global: { global: {
config: globalStore.config, config: globalStore.config,
path: globalStore.path, path: globalStore.path,
project: globalStore.project, project: globalStore.project,
provider: globalStore.provider, provider: globalStore.provider,
}, },
api: serverSDK.api, api: serverSDK.api,
store: child[0], store: child[0],
setStore: child[1], setStore: child[1],
loadSessions, vcsCache: cache,
translate: language.t, loadSessions,
queryClient, translate: language.t,
session, queryClient,
}), session,
initial ? refreshVcs(directory) : Promise.resolve(), })
])
}) })
booting.set(key, promise) booting.set(key, promise)
@@ -537,43 +457,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
loadLsp() {}, loadLsp() {},
}) })
} }
const updateHomeSession = (info: Parameters<typeof session.remember>[0]) =>
homeSessions.apply({
type: "session.updated",
properties: { sessionID: info.id, info },
})
const markSessionListChanged = (event: ServerEvent, directory: string, previousDirectory?: string) => {
if (SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) {
const key = directoryKey(directory)
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
}
if (!previousDirectory || previousDirectory === directory) return
const key = directoryKey(previousDirectory)
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
}
const toDirectoryEvent = (event: ServerEvent) => {
if (event.current?.type === "session.created") return
if (
event.current?.type !== "session.renamed" &&
event.current?.type !== "session.moved" &&
event.current?.type !== "session.usage.updated"
)
return event
const info = session.get(event.current.data.sessionID)
if (info) return { type: "session.updated", properties: { info } }
return event
}
const unsub = serverSDK.event.listen((e) => { const unsub = serverSDK.event.listen((e) => {
const directory = e.name const directory = e.name
const key = directoryKey(directory) const key = directoryKey(directory)
const event = e.details const event = e.details
const eventType: string = event.type const eventType: string = event.type
const previousDirectory = const recent = bootingRoot || Date.now() - bootedAt < 1500
event.current?.type === "session.moved"
? session.get(event.current.data.sessionID)?.location.directory
: undefined
markSessionListChanged(event, directory, previousDirectory)
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.current?.type === "session.created")
@@ -596,41 +487,35 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (event.type === "session.created" || event.type === "session.deleted") { 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])
} }
if (
event.current?.type === "session.renamed" ||
event.current?.type === "session.moved" ||
event.current?.type === "session.usage.updated"
) {
const sessionID = event.current.data.sessionID
const info = session.get(sessionID)
if (info) updateHomeSession(info)
if (!info)
void session
.resolve(sessionID)
.then(() => {
const current = session.get(sessionID)
if (current) updateHomeSession(current)
})
.catch(() => undefined)
}
homeSessions.refresh(event.type) homeSessions.refresh(event.type)
catalog.handleEvent({ type: eventType, directory }) if (eventType === "integration.connection.updated") void refreshProviders()
connection.handleEvent({ type: eventType, directory })
if (directory === "global") { if (directory === "global") {
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
void activeSessionsQuery.refetch()
applyGlobalEvent({ applyGlobalEvent({
event, event,
project: globalStore.project, project: globalStore.project,
refresh: () => void bootstrap.refetch(), refresh: () => {
if (recent) return
bootstrap.refetch()
},
setGlobalProject: setProjects, setGlobalProject: setProjects,
}) })
if ( if (
eventType === "config.updated" || eventType === "config.updated" ||
eventType === "catalog.updated" ||
eventType === "agent.updated" || eventType === "agent.updated" ||
eventType === "project.directories.updated" eventType === "project.directories.updated"
) )
bootstrap.refetch() bootstrap.refetch()
if (eventType === "global.disposed") Object.keys(children.children).filter(children.active).forEach(queue.push) if (eventType === "server.connected" || eventType === "global.disposed") {
if (recent) return
for (const directory of Object.keys(children.children)) {
if (!children.active(directory)) continue
queue.push(directory)
}
}
return return
} }
@@ -651,6 +536,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
event.current?.type === "session.moved" || event.current?.type === "session.moved" ||
// event.current?.type === "session.archived" || // event.current?.type === "session.archived" ||
event.current?.type === "session.forked" || event.current?.type === "session.forked" ||
eventType === "command.updated" ||
eventType === "config.updated" || eventType === "config.updated" ||
eventType === "agent.updated" eventType === "agent.updated"
) )
@@ -658,39 +544,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key)) if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key))
if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key)) if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key))
const [store, setStore] = existing const [store, setStore] = existing
if (eventType === "agent.updated") applyDirectoryEvent({
void queryClient event,
.fetchQuery(queryOptionsApi.agents(key)) directory,
.then((data) => setStore("agent", data)) store,
.catch(() => {}) setStore,
if (eventType === "command.updated") push: (directory) => {
void loadCommands(directory, serverSDK.api.command) if (children.active(directory)) queue.push(directory)
.then((commands) => setStore("command", commands)) },
.catch(() => {}) retainedLimit: sessionMeta.get(key)?.limit,
if (eventType === "project.directories.updated") void bootstrap.refetch() sessionContent: false,
const projected = toDirectoryEvent(event) permission: session.data.permission,
if (projected) vcsCache: children.vcsCache.get(key),
applyDirectoryEvent({ loadLsp: () => {
event: projected, if (!children.active(key)) return
directory, void queryClient.fetchQuery(queryOptionsApi.lsp(key))
store, },
setStore, loadReferences: () => {
push: (directory) => { if (!children.active(key)) return
if (children.active(directory)) queue.push(directory) void queryClient.fetchQuery(queryOptionsApi.references(key))
}, },
retainedLimit: sessionMeta.get(key)?.limit, })
sessionContent: false,
permission: session.data.permission,
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
},
loadReferences: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.references(key))
},
})
}) })
onCleanup(unsub) onCleanup(unsub)
@@ -703,6 +577,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
} }
}) })
onMount(() => {
if (typeof requestAnimationFrame === "function") {
eventFrame = requestAnimationFrame(() => {
eventFrame = undefined
eventTimer = setTimeout(() => {
eventTimer = undefined
void serverSDK.event.start()
}, 0)
})
} else {
eventTimer = setTimeout(() => {
eventTimer = undefined
void serverSDK.event.start()
}, 0)
}
})
const projectApi = { const projectApi = {
loadSessions, loadSessions,
meta(directory: string, patch: ProjectMeta) { meta(directory: string, patch: ProjectMeta) {
@@ -743,11 +634,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
peek: children.peek, peek: children.peek,
disableMcp: children.disableMcp, disableMcp: children.disableMcp,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
refreshProviders: catalog.refreshActive, refreshProviders,
// bootstrap, // bootstrap,
updateConfig: updateConfigMutation.mutateAsync, updateConfig: updateConfigMutation.mutateAsync,
project: projectApi, project: projectApi,
session: Object.assign(session, { hydrate: hydrateSession }), session,
homeSessions, homeSessions,
mcp: { mcp: {
toggle: async (directory: string, name: string) => { toggle: async (directory: string, name: string) => {
@@ -808,13 +699,13 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
name: "ServerSync", name: "ServerSync",
// Returns an accessor so the resolved server can change reactively without // Returns an accessor so the resolved server can change reactively without
// re-instantiating the subtree (mirrors useServerSDK). // re-instantiating the subtree (mirrors useServerSDK).
init: (props: { server?: ServerConnection.Any }) => { init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
const global = useGlobal() const global = useGlobal()
const language = useLanguage() const language = useLanguage()
const server = useServer() const server = useServer()
return createMemo<ServerSync>(() => { return createMemo<ServerSync>(() => {
const conn = props.server ?? server.current const conn = props.server?.() ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable")) if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
return global.ensureServerCtx(conn).sync return global.ensureServerCtx(conn).sync
}) })
@@ -1,51 +0,0 @@
import { expect, test } from "bun:test"
import { QueryClient } from "@tanstack/solid-query"
import { ServerScope } from "@/utils/server-scope"
import { createCatalogSync } from "./catalog"
import { pathKey } from "@/utils/path-key"
test("invalidates the catalog for the event location", async () => {
const queryClient = new QueryClient()
const one = [ServerScope.local, "/one", "providers"] as const
const integrations = [ServerScope.local, "/one", "integrations"] as const
const two = [ServerScope.local, "/two", "providers"] as const
queryClient.setQueryData(one, { providers: ["one"] })
queryClient.setQueryData(integrations, { integrations: ["one"] })
queryClient.setQueryData(two, { providers: ["two"] })
const catalog = createCatalogSync({
scope: ServerScope.local,
queryClient,
active: () => [pathKey("/one"), pathKey("/two")],
load: async () => {},
})
catalog.handleEvent({ type: "catalog.updated", directory: "/one" })
await Bun.sleep(0)
expect(queryClient.getQueryState(one)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(integrations)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(two)?.isInvalidated).toBe(false)
})
test("invalidates global and active catalogs after connection", async () => {
const queryClient = new QueryClient()
const global = [ServerScope.local, null, "providers"] as const
const active = [ServerScope.local, "/active", "providers"] as const
const passive = [ServerScope.local, "/passive", "providers"] as const
queryClient.setQueryData(global, {})
queryClient.setQueryData(active, {})
queryClient.setQueryData(passive, {})
const catalog = createCatalogSync({
scope: ServerScope.local,
queryClient,
active: () => [pathKey("/active")],
load: async () => {},
})
catalog.handleEvent({ type: "server.connected", directory: "global" })
await Bun.sleep(0)
expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(active)?.isInvalidated).toBe(true)
expect(queryClient.getQueryState(passive)?.isInvalidated).toBe(false)
})
@@ -1,53 +0,0 @@
import type { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope"
import { pathKey, type PathKey } from "@/utils/path-key"
type CatalogEvent = {
type: string
directory: string
}
export function createCatalogSync(input: {
scope: ServerScope
queryClient: QueryClient
active: () => PathKey[]
load: (directory: PathKey | null) => Promise<void>
}) {
function handleEvent(event: CatalogEvent) {
if (event.type === "server.connected") {
void refreshActive().catch(() => undefined)
return
}
if (
event.type === "catalog.updated" ||
event.type === "integration.updated" ||
event.type === "integration.connection.updated"
) {
void refresh(event.directory === "global" ? null : pathKey(event.directory)).catch(() => undefined)
}
}
async function refresh(directory: PathKey | null) {
await Promise.all(
["providers", "integrations"].map((resource) =>
input.queryClient.invalidateQueries({
queryKey: [input.scope, directory, resource],
exact: true,
refetchType: "none",
}),
),
)
await input.load(directory)
}
function refreshActive() {
return Promise.all([null, ...new Set(input.active())].map(refresh)).then(() => undefined)
}
return {
handleEvent,
refresh,
refreshActive,
}
}
@@ -1,23 +0,0 @@
import { expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createConnectionSync } from "./connection"
test("invalidates disconnected data and synchronizes after the handshake", () => {
const calls: string[] = []
const dispose = createRoot((dispose) => {
const [status, setStatus] = createSignal<"connecting" | "connected" | "reconnecting">("connecting")
const connection = createConnectionSync({
status,
invalidate: () => calls.push("invalidate"),
connected: () => calls.push("connected"),
})
connection.handleEvent({ type: "server.connected", directory: "global" })
expect(calls).toContain("connected")
connection.handleEvent({ type: "server.connected", directory: "/repo" })
expect(calls.filter((call) => call === "connected")).toHaveLength(1)
setStatus("connected")
return dispose
})
dispose()
})
@@ -1,22 +0,0 @@
import { createEffect, type Accessor } from "solid-js"
import type { ServerConnectionStatus } from "../server-sdk"
export function createConnectionSync(input: {
status: Accessor<ServerConnectionStatus>
invalidate: () => void
connected: (info: { reconnect: boolean }) => void
}) {
createEffect(() => {
if (input.status() === "connected") return
input.invalidate()
})
let connectedOnce = false
function handleEvent(event: { type: string; directory: string }) {
if (event.directory !== "global" || event.type !== "server.connected") return
input.connected({ reconnect: connectedOnce })
connectedOnce = true
}
return { handleEvent }
}
@@ -1,21 +0,0 @@
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { createQuery } from "@tanstack/solid-query"
import type { Accessor } from "solid-js"
export function useIntegrations(directory: Accessor<string | undefined>) {
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const query = createQuery(() => {
const value = directory()
return {
...serverSync().queryOptions.integrations(value ? pathKey(value) : null),
enabled: serverSDK().connection.status() === "connected",
}
})
return {
list: () => (query.isSuccess || query.isRefetchError ? query.data : []),
}
}
+4 -6
View File
@@ -1,4 +1,6 @@
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { decode64 } from "@/utils/base64"
import { useParams } from "@solidjs/router"
import { Iterable, pipe } from "effect" import { Iterable, pipe } from "effect"
import { createEffect, createMemo, type Accessor } from "solid-js" import { createEffect, createMemo, type Accessor } from "solid-js"
import { selectProviderCatalog } from "./provider-catalog" import { selectProviderCatalog } from "./provider-catalog"
@@ -17,7 +19,8 @@ const popularProviderSet = new Set(popularProviders)
export function useProviders(directory: Accessor<string | undefined>) { export function useProviders(directory: Accessor<string | undefined>) {
const serverSync = useServerSync() const serverSync = useServerSync()
const dir = directory const params = useParams()
const dir = () => (directory ? directory() : decode64(params.dir))
const providers = () => { const providers = () => {
const value = dir() const value = dir()
const projectStore = value ? serverSync().child(value)[0] : undefined const projectStore = value ? serverSync().child(value)[0] : undefined
@@ -36,11 +39,6 @@ export function useProviders(directory: Accessor<string | undefined>) {
} }
return { return {
ready: () => {
const value = dir()
if (!value) return false
return serverSync().child(value)[0].provider_ready
},
all: () => providers().all, all: () => providers().all,
default: () => providers().default, default: () => providers().default,
popular: () => popular: () =>
+3 -46
View File
@@ -673,17 +673,6 @@ export const dict = {
"session.error.notFound": "This session cannot be found", "session.error.notFound": "This session cannot be found",
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.", "session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab", "session.error.notFound.closeTab": "Close Tab",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.combine": "{{first}} and {{second}}",
"session.background.shell.one": "{{count}} shell",
"session.background.shell.other": "{{count}} shells",
"session.background.subagent.one": "{{count}} subagent",
"session.background.subagent.other": "{{count}} subagents",
"command.session.background": "Move to background",
"session.timeline.notice.finished": "{{actor}} finished",
"session.timeline.notice.failed": "{{actor}} failed",
"session.timeline.notice.cancelled": "{{actor}} cancelled",
"session.error.serverConnection": "Can't connect to this server", "session.error.serverConnection": "Can't connect to this server",
"session.review.filesChanged": "Files Changed {{count}}", "session.review.filesChanged": "Files Changed {{count}}",
"session.review.change.one": "Change", "session.review.change.one": "Change",
@@ -903,46 +892,14 @@ export const dict = {
"settings.section.desktop": "Desktop", "settings.section.desktop": "Desktop",
"settings.section.server": "Server", "settings.section.server": "Server",
"settings.tab.general": "General", "settings.tab.general": "General",
"settings.tab.preferences": "Preferences",
"settings.tab.shortcuts": "Shortcuts", "settings.tab.shortcuts": "Shortcuts",
"settings.tab.notifications": "Notifications",
"settings.tab.projects": "Projects",
"settings.tab.extensions": "Extensions",
"settings.preferences.description": "Customize preferences and theme and default behavior",
"settings.appearance.description": "Customize theme and fonts",
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
"settings.shortcuts.description": "Customize shortcuts for common actions",
"settings.servers.description": "Manage server connections",
"settings.projects.title": "Projects",
"settings.projects.description": "Manage project settings on this server",
"settings.projects.empty": "No projects found",
"settings.projects.server.all": "All servers",
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
"settings.extensions.description": "Manage extensions available on this server",
"settings.extensions.tab.mcps": "MCPs",
"settings.extensions.tab.skills": "Skills",
"settings.extensions.availableAll": "Available to all projects",
"settings.extensions.manageConfig": "Manage in opencode.json",
"settings.extensions.addSkills": "How to add skills",
"settings.desktop.section.wsl": "WSL", "settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration", "settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"dialog.server.authenticate.title": "Authenticate",
"project.settings.general.description": "Manage project name and appearance",
"project.settings.scripts": "Scripts",
"project.settings.scripts.description": "Configure scripts for this project",
"project.settings.extensions.description": "View extensions available to this project",
"project.settings.extensions.tab.lsps": "LSPs",
"project.settings.extensions.added": "Added to this project",
"project.settings.extensions.shared": "Shared with all projects",
"project.settings.extensions.lsp.detected": "Detected language servers",
"project.settings.extensions.lsp.description": "Auto-detected from file types",
"project.settings.extensions.setupRequired": "Setup required",
"settings.general.section.appearance": "Appearance", "settings.general.section.appearance": "Appearance",
"settings.general.section.general": "General",
"settings.general.section.advanced": "Advanced", "settings.general.section.advanced": "Advanced",
"settings.general.section.notifications": "Desktop notifications", "settings.general.section.notifications": "System notifications",
"settings.general.section.updates": "Updates", "settings.general.section.updates": "Updates",
"settings.general.section.sounds": "Sound effects", "settings.general.section.sounds": "Sound effects",
"settings.general.section.feed": "Feed", "settings.general.section.feed": "Feed",
@@ -1103,7 +1060,7 @@ export const dict = {
"settings.shortcuts.group.prompt": "Prompt", "settings.shortcuts.group.prompt": "Prompt",
"settings.providers.title": "Providers", "settings.providers.title": "Providers",
"settings.providers.description": "Connect and manage model providers", "settings.providers.description": "Provider settings will be configurable here.",
"settings.providers.section.connected": "Connected providers", "settings.providers.section.connected": "Connected providers",
"settings.providers.connected.empty": "No connected providers", "settings.providers.connected.empty": "No connected providers",
"settings.providers.connected.environmentDescription": "Connected from your environment variables", "settings.providers.connected.environmentDescription": "Connected from your environment variables",
@@ -1114,7 +1071,7 @@ export const dict = {
"settings.providers.tag.custom": "Custom", "settings.providers.tag.custom": "Custom",
"settings.providers.tag.other": "Other", "settings.providers.tag.other": "Other",
"settings.models.title": "Models", "settings.models.title": "Models",
"settings.models.description": "Choose which models appear in model picker", "settings.models.description": "Model settings will be configurable here.",
"settings.agents.title": "Agents", "settings.agents.title": "Agents",
"settings.agents.description": "Agent settings will be configurable here.", "settings.agents.description": "Agent settings will be configurable here.",
"settings.commands.title": "Commands", "settings.commands.title": "Commands",
+230
View File
@@ -0,0 +1,230 @@
import { describe, expect, test } from "bun:test"
import { desktopNativePluralCategories } from "./desktop-native"
const appLocales = [
"ar",
"br",
"bs",
"da",
"de",
"es",
"fr",
"ja",
"ko",
"no",
"pl",
"ru",
"uk",
"th",
"tr",
"zh",
"zht",
"hi",
"nl",
"id",
"vi",
"it",
"ur",
"pa",
"az",
"fi",
"sv",
"am",
"bg",
"bn",
"ca",
"cs",
"dv",
"dz",
"el",
"et",
"fa",
"fo",
"hr",
"hu",
"hy",
"is",
"ka",
"km",
"lo",
"lt",
"lv",
"mk",
"mn",
"ms",
"my",
"ne",
"ro",
"si",
"sk",
"sl",
"sq",
"sr",
"tg",
"tk",
"uz",
] as const
const desktopLocales = appLocales
const pluralCategories = new Map(
appLocales.map(
(locale) =>
[
locale,
desktopNativePluralCategories(locale).filter((category) => category !== "one" && category !== "other"),
] as const,
),
)
const domains = [
{
name: "app",
source: "./en.ts",
target: (locale: string) => `./${locale}.ts`,
locales: appLocales,
},
{
name: "ui",
source: "../../../ui/src/i18n/en.ts",
target: (locale: string) => `../../../ui/src/i18n/${locale}.ts`,
locales: appLocales,
},
{
name: "desktop",
source: "../../../desktop/src/renderer/i18n/en.ts",
target: (locale: string) => `../../../desktop/src/renderer/i18n/${locale}.ts`,
locales: desktopLocales,
},
] as const
describe("i18n parity", () => {
test("non-English locales have every English key and required plural variants", async () => {
for (const domain of domains) {
const source = await dictionary(domain.source)
for (const locale of domain.locales) {
const target = await dictionary(domain.target(locale))
const missing = Object.keys(source).filter((key) => !Object.hasOwn(target, key))
const extra = Object.keys(target)
.filter((key) => !Object.hasOwn(source, key))
.sort()
const expected = pluralFamilies(source)
.flatMap((key) => (pluralCategories.get(locale) ?? []).map((category) => `${key}.${category}`))
.sort()
expect({ domain: domain.name, locale, missing, extra }).toEqual({
domain: domain.name,
locale,
missing: [],
extra: expected,
})
}
}
})
test("non-English locales preserve English placeholders", async () => {
for (const domain of domains) {
const source = await dictionary(domain.source)
for (const locale of domain.locales) {
const target = await dictionary(domain.target(locale))
const mismatched = Object.keys(source).filter(
(key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(),
)
const pluralMismatched = pluralFamilies(source).flatMap((key) =>
(pluralCategories.get(locale) ?? [])
.map((category) => `${key}.${category}`)
.filter((variant) => placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join()),
)
expect({ domain: domain.name, locale, mismatched, pluralMismatched }).toEqual({
domain: domain.name,
locale,
mismatched: [],
pluralMismatched: [],
})
}
}
})
test("non-English locales translate targeted unseen session keys", async () => {
const source = await dictionary("./en.ts")
for (const locale of appLocales) {
const target = await dictionary(`./${locale}.ts`)
for (const key of ["command.session.previous.unseen", "command.session.next.unseen"]) {
expect(target[key]).toBeDefined()
expect(target[key]).not.toBe(source[key])
}
}
})
test("changed-file summary keys preserve rendered English copy and localize complete phrases", async () => {
const source = await dictionary("../../../ui/src/i18n/en.ts")
expect(source["ui.sessionTurn.diffs.changed.one"].replace("{{count}}", "1")).toBe("1 Changed file")
expect(source["ui.sessionTurn.diffs.changed.other"].replace("{{count}}", "2")).toBe("2 Changed files")
expect(source["ui.sessionTurn.diffs.changed"]).toBeUndefined()
for (const locale of appLocales) {
const target = await dictionary(`../../../ui/src/i18n/${locale}.ts`)
for (const key of ["ui.sessionTurn.diffs.changed.one", "ui.sessionTurn.diffs.changed.other"]) {
expect(target[key].trim()).not.toBe("")
expect(placeholders(target[key])).toEqual(["count"])
}
}
})
})
describe("i18n plural parity", () => {
test("locale-specific categories exist and preserve count placeholders", async () => {
for (const domain of domains.slice(0, 2)) {
const source = await dictionary(domain.source)
const families = pluralFamilies(source)
for (const locale of domain.locales) {
const target = await dictionary(domain.target(locale))
const missing = families.flatMap((key) =>
(pluralCategories.get(locale) ?? [])
.map((category) => `${key}.${category}`)
.filter((variant) => !Object.hasOwn(target, variant)),
)
const mismatched = families.flatMap((key) =>
(pluralCategories.get(locale) ?? [])
.map((category) => `${key}.${category}`)
.filter(
(variant) =>
Object.hasOwn(target, variant) &&
placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join(),
),
)
expect({ domain: domain.name, locale, missing, mismatched }).toEqual({
domain: domain.name,
locale,
missing: [],
mismatched: [],
})
}
}
})
})
async function dictionary(file: string) {
const module: unknown = await import(file)
if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) {
throw new Error(`Invalid translation dictionary: ${file}`)
}
return module.dict
}
function isDictionary(value: unknown): value is Record<string, string> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false
return Object.values(value).every((item) => typeof item === "string")
}
function placeholders(value: string) {
return Array.from(value.matchAll(/{{\s*([^}]+?)\s*}}/g), (match) => match[1]).sort()
}
function pluralFamilies(dictionary: Record<string, string>) {
return Object.keys(dictionary)
.filter(
(key) =>
key.endsWith(".one") &&
dictionary[key].includes("{{count}}") &&
dictionary[`${key.slice(0, -4)}.other`]?.includes("{{count}}"),
)
.map((key) => key.slice(0, -4))
}
+9 -9
View File
@@ -2,7 +2,7 @@ import { DataProvider } from "@opencode-ai/session-ui/context"
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 { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, type ParentProps, Show } from "solid-js" import { type Accessor, createEffect, createMemo, createResource, onCleanup, type ParentProps, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local" import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
@@ -15,9 +15,9 @@ import { useServerSync } from "@/context/server-sync"
export function DirectoryDataProvider( export function DirectoryDataProvider(
props: ParentProps<{ props: ParentProps<{
directory: string directory: string | Accessor<string>
draftID?: string draftID?: string
server?: ServerConnection.Key server?: Accessor<ServerConnection.Key | undefined>
}>, }>,
) { ) {
const location = useLocation() const location = useLocation()
@@ -25,17 +25,17 @@ export function DirectoryDataProvider(
const params = useParams() const params = useParams()
const sync = useSync() const sync = useSync()
const serverSync = useServerSync() const serverSync = useServerSync()
const language = useLanguage() const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
const directory = () => props.directory
const slug = createMemo(() => base64Encode(directory())) const slug = createMemo(() => base64Encode(directory()))
const href = (sessionID: string) => { const href = (sessionID: string) => {
if (props.server) return sessionHref(props.server, sessionID) const server = props.server?.()
if (server) return sessionHref(server, sessionID)
return `/${slug()}/session/${sessionID}` return `/${slug()}/session/${sessionID}`
} }
createEffect(() => { createEffect(() => {
// A draft lives at /new-session?draftId=… and has no directory segment to normalize. // A draft lives at /new-session?draftId=… and has no directory segment to normalize.
if (props.draftID || props.server) return if (props.draftID || props.server?.()) return
const next = sync().data.path.directory const next = sync().data.path.directory
if (!next || next === directory()) return if (!next || next === directory()) return
const path = location.pathname.slice(slug().length + 1) const path = location.pathname.slice(slug().length + 1)
@@ -45,8 +45,8 @@ export function DirectoryDataProvider(
createResource( createResource(
() => params.id, () => params.id,
(id) => (id) =>
serverSync() sync()
.session.hydrate(id) .session.sync(id)
.catch(() => {}), .catch(() => {}),
) )
+2 -2
View File
@@ -23,8 +23,8 @@ export function Home() {
> >
<ScrollView <ScrollView
class="h-full [container-type:size]" class="h-full [container-type:size]"
thumbContainer={scroll.viewport.thumbTrack()} thumbContainer={scroll.viewport.thumbTrack}
thumbHoverTarget={scroll.viewport.hoverTarget()} thumbHoverTarget={scroll.viewport.hoverTarget}
viewportRef={scroll.viewport.setViewport} viewportRef={scroll.viewport.setViewport}
onScroll={(event) => scroll.viewport.update(event.currentTarget.scrollTop)} onScroll={(event) => scroll.viewport.update(event.currentTarget.scrollTop)}
onWheel={scroll.viewport.containOuterWheel} onWheel={scroll.viewport.containOuterWheel}
@@ -1,4 +1,4 @@
import { createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js" import { type Accessor, createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable" import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
@@ -29,16 +29,16 @@ const projectContextMenuID = (server: ServerConnection.Any, directory: string) =
export type HomeProjectsViewProps = { export type HomeProjectsViewProps = {
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
servers: ServerConnection.Any[] servers: Accessor<ServerConnection.Any[]>
projects: LocalProject[] projects: Accessor<LocalProject[]>
recentlyClosed: LocalProject[] recentlyClosed: Accessor<LocalProject[]>
selection: HomeProjectSelection selection: Accessor<HomeProjectSelection>
homedir: string homedir: Accessor<string>
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
projectsForServer: (server: ServerConnection.Any) => LocalProject[] projectsForServer: (server: ServerConnection.Any) => LocalProject[]
collapsed: (server: ServerConnection.Any) => boolean collapsed: (server: ServerConnection.Any) => boolean
canDefaultServer: boolean canDefaultServer: Accessor<boolean>
defaultServerKey: ServerConnection.Key | null | undefined defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
canRevealProject: (server: ServerConnection.Any) => boolean canRevealProject: (server: ServerConnection.Any) => boolean
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
onWheel: (event: WheelEvent) => void onWheel: (event: WheelEvent) => void
@@ -81,7 +81,9 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
> >
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3"> <div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div> <div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}> <Show
when={props.servers().length === 1 && !(props.projects().length === 0 && props.recentlyClosed().length > 0)}
>
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}> <TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
<IconButtonV2 <IconButtonV2
data-action="home-add-project" data-action="home-add-project"
@@ -89,8 +91,8 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
size="large" size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted" class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<IconV2 name="folder-add-left" />} icon={<IconV2 name="folder-add-left" />}
disabled={props.serverHealth(props.servers[0])?.healthy === false} disabled={props.serverHealth(props.servers()[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers[0])} onClick={() => props.onChooseProject(props.servers()[0])}
aria-label={props.language.t("home.project.add")} aria-label={props.language.t("home.project.add")}
/> />
</TooltipV2> </TooltipV2>
@@ -98,20 +100,25 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
</div> </div>
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink"> <ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
<Show <Show
when={props.servers.length > 1} when={props.servers().length > 1}
fallback={ fallback={
<div class="pr-3"> <div class="pr-3">
<Show <Show
when={props.projects.length > 0} when={props.projects().length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />} fallback={<HomeProjectEmpty {...props} server={props.servers()[0]} items={props.recentlyClosed()} />}
> >
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} /> <HomeProjectList
{...props}
{...contextMenuProps}
server={props.servers()[0]}
items={props.projects()}
/>
</Show> </Show>
</div> </div>
} }
> >
<div class="flex min-w-0 flex-col gap-4 pr-3"> <div class="flex min-w-0 flex-col gap-4 pr-3">
<For each={props.servers}> <For each={props.servers()}>
{(item) => { {(item) => {
const projects = () => props.projectsForServer(item) const projects = () => props.projectsForServer(item)
const healthy = () => !!props.serverHealth(item)?.healthy const healthy = () => !!props.serverHealth(item)?.healthy
@@ -123,7 +130,7 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
server={item} server={item}
{...props} {...props}
{...contextMenuProps} {...contextMenuProps}
selected={props.selection.server === ServerConnection.key(item) && !props.selection.directory} selected={props.selection().server === ServerConnection.key(item) && !props.selection().directory}
collapsed={collapsed()} collapsed={collapsed()}
health={props.serverHealth(item)} health={props.serverHealth(item)}
/> />
@@ -270,8 +277,8 @@ function HomeServerRow(props: {
<ServerRowMenuView <ServerRowMenuView
server={props.server} server={props.server}
labels={serverMenuLabels(props.language)} labels={serverMenuLabels(props.language)}
canDefault={props.canDefaultServer} canDefault={props.canDefaultServer()}
isDefault={props.defaultServerKey === ServerConnection.key(props.server)} isDefault={props.defaultServerKey() === ServerConnection.key(props.server)}
canRemove={props.canRemoveServer(props.server)} canRemove={props.canRemoveServer(props.server)}
onEdit={props.onEditServer} onEdit={props.onEditServer}
onSetDefault={() => props.onSetDefaultServer(props.server)} onSetDefault={() => props.onSetDefaultServer(props.server)}
@@ -332,7 +339,7 @@ function HomeProjectList(props: HomeProjectListProps) {
const source = event.operation.source const source = event.operation.source
if (event.canceled || !isSortable(source)) return if (event.canceled || !isSortable(source)) return
if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index) if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index)
if (props.selection.server !== ServerConnection.key(props.server)) if (props.selection().server !== ServerConnection.key(props.server))
props.onSelectProject(props.server, source.id.toString()) props.onSelectProject(props.server, source.id.toString())
}} }}
> >
@@ -343,7 +350,7 @@ function HomeProjectList(props: HomeProjectListProps) {
row's sortable unregisters on unmount) and discarding animations. row's sortable unregisters on unmount) and discarding animations.
String keys keep row elements alive and move them on reorder. */} String keys keep row elements alive and move them on reorder. */}
<For each={props.items.map((project) => project.worktree)}> <For each={props.items.map((project) => project.worktree)}>
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index()} />} {(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
</For> </For>
</div> </div>
</DragDropProvider> </DragDropProvider>
@@ -353,7 +360,7 @@ function HomeProjectList(props: HomeProjectListProps) {
function HomeProjectSlot( function HomeProjectSlot(
props: HomeProjectListProps & { props: HomeProjectListProps & {
worktree: string worktree: string
index: number index: () => number
}, },
) { ) {
const initial = props.items.find((item) => item.worktree === props.worktree) const initial = props.items.find((item) => item.worktree === props.worktree)
@@ -369,9 +376,10 @@ function HomeProjectSlot(
project={project()} project={project()}
server={props.server} server={props.server}
index={props.index} index={props.index}
serverSelected={props.selection.server === ServerConnection.key(props.server)} serverSelected={props.selection().server === ServerConnection.key(props.server)}
selected={ selected={
props.selection.server === ServerConnection.key(props.server) && props.selection.directory === props.worktree props.selection().server === ServerConnection.key(props.server) &&
props.selection().directory === props.worktree
} }
unseen={props.unseenCount(props.server, project())} unseen={props.unseenCount(props.server, project())}
/> />
@@ -417,7 +425,7 @@ function HomeRecentlyClosedRow(
) { ) {
const unreachable = () => props.serverHealth(props.server)?.healthy === false const unreachable = () => props.serverHealth(props.server)?.healthy === false
const path = () => { const path = () => {
const home = props.homedir const home = props.homedir()
const worktree = props.project.worktree const worktree = props.project.worktree
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}` if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
return worktree return worktree
@@ -443,7 +451,7 @@ function HomeProjectRow(
HomeProjectsContextMenuProps & { HomeProjectsContextMenuProps & {
project: LocalProject project: LocalProject
server: ServerConnection.Any server: ServerConnection.Any
index: number index: () => number
serverSelected: boolean serverSelected: boolean
selected: boolean selected: boolean
unseen: number unseen: number
@@ -456,7 +464,7 @@ function HomeProjectRow(
return props.project.worktree return props.project.worktree
}, },
get index() { get index() {
return props.index return props.index()
}, },
}) })
let pointerDownSelected: boolean | undefined let pointerDownSelected: boolean | undefined
@@ -6,16 +6,16 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
return ( return (
<HomeProjectsView <HomeProjectsView
language={props.projects.copy.language} language={props.projects.copy.language}
servers={props.projects.server.list()} servers={props.projects.server.list}
projects={props.projects.project.list()} projects={props.projects.project.list}
recentlyClosed={props.projects.project.recentlyClosed()} recentlyClosed={props.projects.project.recentlyClosed}
selection={props.projects.selection.value()} selection={props.projects.selection.value}
homedir={props.projects.project.homedir()} homedir={props.projects.project.homedir}
serverHealth={props.projects.server.health} serverHealth={props.projects.server.health}
projectsForServer={props.projects.server.projects} projectsForServer={props.projects.server.projects}
collapsed={props.projects.server.collapsed} collapsed={props.projects.server.collapsed}
canDefaultServer={props.projects.server.canDefault()} canDefaultServer={props.projects.server.canDefault}
defaultServerKey={props.projects.server.defaultKey()} defaultServerKey={props.projects.server.defaultKey}
canRevealProject={props.projects.project.canReveal} canRevealProject={props.projects.project.canReveal}
unseenCount={props.projects.project.unseenCount} unseenCount={props.projects.project.unseenCount}
onWheel={props.scroll.viewport.containWheel} onWheel={props.scroll.viewport.containWheel}
@@ -60,7 +60,7 @@ export function createHomeSessionsController(home: HomeController) {
})) }))
const sessionLoad = useQuery(() => ({ const sessionLoad = useQuery(() => ({
queryKey: homeSessions().indexKey, queryKey: homeSessions().indexKey,
enabled: home.server.focusedContext()?.sdk.connection.status() === "connected", enabled: !!home.server.focusedContext(),
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
const ctx = home.server.focusedContext() const ctx = home.server.focusedContext()
if (!ctx) return { sessions: [], eventSequence: 0 } if (!ctx) return { sessions: [], eventSequence: 0 }
@@ -295,13 +295,13 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
export type HomeSessionsController = ReturnType<typeof createHomeSessionsController> export type HomeSessionsController = ReturnType<typeof createHomeSessionsController>
export function HomeSessionStatusController(props: { export function HomeSessionStatusController(props: {
server: ServerConnection.Key server: Accessor<ServerConnection.Key>
record: HomeSessionRecord record: HomeSessionRecord
isOpenTab: (record: HomeSessionRecord) => boolean isOpenTab: (record: HomeSessionRecord) => boolean
render: (state: { unread: Accessor<boolean>; loading: Accessor<boolean>; open: Accessor<boolean> }) => JSX.Element render: (state: { unread: Accessor<boolean>; loading: Accessor<boolean>; open: Accessor<boolean> }) => JSX.Element
}) { }) {
const avatar = useSessionTabAvatarState( const avatar = useSessionTabAvatarState(
() => props.server, props.server,
() => props.record.session.location.directory, () => props.record.session.location.directory,
() => props.record.session.id, () => props.record.session.id,
) )
@@ -1,5 +1,5 @@
import type { SessionInfo } from "@opencode-ai/client/promise" import type { SessionInfo } from "@opencode-ai/client/promise"
import { createMemo, For, Show, Suspense } from "solid-js" import { type Accessor, createMemo, For, Show, Suspense } 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"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
@@ -38,17 +38,17 @@ function isBackgroundOpen(event: MouseEvent) {
export type HomeSessionsViewProps = { export type HomeSessionsViewProps = {
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
groups: HomeSessionGroup[] groups: Accessor<HomeSessionGroup[]>
showProjectName: boolean showProjectName: Accessor<boolean>
server: ServerConnection.Key server: Accessor<ServerConnection.Key>
canCreateSession: boolean canCreateSession: Accessor<boolean>
searchValue: string searchValue: Accessor<string>
searchPlaceholder: string searchPlaceholder: Accessor<string>
searchOpen: boolean searchOpen: Accessor<boolean>
searchLoading: boolean searchLoading: Accessor<boolean>
searchResults: HomeSessionRecord[] searchResults: Accessor<HomeSessionRecord[]>
searchActive: string searchActive: Accessor<string>
searchNoResultsLabel: string searchNoResultsLabel: Accessor<string>
titleOpacity: (id: HomeSessionGroup["id"]) => number titleOpacity: (id: HomeSessionGroup["id"]) => number
isOpenTab: (record: HomeSessionRecord) => boolean isOpenTab: (record: HomeSessionRecord) => boolean
onCreateSession: () => void onCreateSession: () => void
@@ -81,7 +81,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}> <div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
<HomeSessionSearch {...props} /> <HomeSessionSearch {...props} />
<Suspense> <Suspense>
<Show when={props.groups.length > 0 && props.canCreateSession}> <Show when={props.groups().length > 0 && props.canCreateSession()}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]"> <div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2 <ButtonV2
data-action="home-new-session" data-action="home-new-session"
@@ -113,16 +113,16 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
} }
> >
<Show <Show
when={props.groups.length > 0} when={props.groups().length > 0}
fallback={ fallback={
<HomeSessionsEmpty <HomeSessionsEmpty
onNewSession={props.canCreateSession ? props.onCreateSession : undefined} onNewSession={props.canCreateSession() ? props.onCreateSession : undefined}
language={props.language} language={props.language}
/> />
} }
> >
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16"> <div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
<For each={props.groups}> <For each={props.groups()}>
{(group, index) => ( {(group, index) => (
<> <>
<HomeSessionGroupHeader <HomeSessionGroupHeader
@@ -132,7 +132,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
elevated={index() === 0} elevated={index() === 0}
/> />
<div <div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups.length - 1 ? "" : "mb-6"}`} class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups().length - 1 ? "" : "mb-6"}`}
> >
<For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For> <For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
</div> </div>
@@ -205,7 +205,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
return ( return (
<div class="w-full"> <div class="w-full">
<div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full"> <div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full">
<Show when={props.searchOpen}> <Show when={props.searchOpen()}>
<div <div
data-component="home-session-search-panel" data-component="home-session-search-panel"
class={` class={`
@@ -217,7 +217,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
<div class="flex flex-col pt-9"> <div class="flex flex-col pt-9">
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4"> <div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
<Show <Show
when={!props.searchLoading} when={!props.searchLoading()}
fallback={ fallback={
<div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]"> <div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
<Spinner class="size-4" /> <Spinner class="size-4" />
@@ -225,7 +225,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
} }
> >
<Show <Show
when={props.searchResults.length > 0} when={props.searchResults().length > 0}
fallback={ fallback={
<p <p
class={` class={`
@@ -233,7 +233,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
text-v2-text-text-muted [font-weight:440] text-v2-text-text-muted [font-weight:440]
`} `}
> >
{props.searchNoResultsLabel} {props.searchNoResultsLabel()}
</p> </p>
} }
> >
@@ -248,12 +248,12 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
</p> </p>
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}> <ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
<div class="flex flex-col gap-px pb-2"> <div class="flex flex-col gap-px pb-2">
<For each={props.searchResults}> <For each={props.searchResults()}>
{(record) => ( {(record) => (
<HomeSessionSearchResultRow <HomeSessionSearchResultRow
{...props} {...props}
record={record} record={record}
selected={props.searchActive === homeSessionSearchKey(record)} selected={props.searchActive() === homeSessionSearchKey(record)}
/> />
)} )}
</For> </For>
@@ -280,14 +280,16 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
relative z-20 min-w-0 flex-1 border-0 bg-transparent outline-0 relative z-20 min-w-0 flex-1 border-0 bg-transparent outline-0
text-v2-text-text-base [font-weight:440] placeholder:text-v2-text-text-faint text-v2-text-text-base [font-weight:440] placeholder:text-v2-text-text-faint
`} `}
value={props.searchValue} value={props.searchValue()}
placeholder={props.searchPlaceholder} placeholder={props.searchPlaceholder()}
aria-label={props.searchPlaceholder} aria-label={props.searchPlaceholder()}
aria-expanded={props.searchOpen} aria-expanded={props.searchOpen()}
aria-controls={HOME_SESSION_SEARCH_RESULTS_ID} aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
aria-autocomplete="list" aria-autocomplete="list"
aria-activedescendant={ aria-activedescendant={
props.searchActive && props.searchOpen ? `home-session-search-option-${props.searchActive}` : undefined props.searchActive() && props.searchOpen()
? `home-session-search-option-${props.searchActive()}`
: undefined
} }
onFocus={props.onSearchFocus} onFocus={props.onSearchFocus}
onInput={(event) => props.onSearchInput(event.currentTarget.value)} onInput={(event) => props.onSearchInput(event.currentTarget.value)}
@@ -298,7 +300,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
event.currentTarget.blur() event.currentTarget.blur()
return return
} }
if (!props.searchOpen || props.searchResults.length === 0) return if (!props.searchOpen() || props.searchResults().length === 0) return
if (event.altKey || event.metaKey) return if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") { if (event.key === "ArrowDown") {
event.preventDefault() event.preventDefault()
@@ -316,14 +318,14 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
} }
}} }}
/> />
<Show when={props.searchValue}> <Show when={props.searchValue()}>
<IconButtonV2 <IconButtonV2
type="button" type="button"
variant="ghost-muted" variant="ghost-muted"
size="small" size="small"
class="relative z-20 shrink-0" class="relative z-20 shrink-0"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />} icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
aria-label={props.searchPlaceholder} aria-label={props.searchPlaceholder()}
onClick={() => { onClick={() => {
props.onSearchClose() props.onSearchClose()
props.onSearchFocus() props.onSearchFocus()
@@ -343,7 +345,7 @@ function HomeSessionSearchResultRow(
}, },
) { ) {
const title = createMemo(() => sessionLabel(props.record.session)) const title = createMemo(() => sessionLabel(props.record.session))
const showProjectName = () => props.showProjectName && props.record.projectName const showProjectName = () => props.showProjectName() && props.record.projectName
const key = () => homeSessionSearchKey(props.record) const key = () => homeSessionSearchKey(props.record)
return ( return (
@@ -414,7 +416,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(() => sessionLabel(props.record.session))
const showProjectName = () => props.showProjectName && props.record.projectName const showProjectName = () => props.showProjectName() && props.record.projectName
return ( return (
<div <div
+11 -11
View File
@@ -11,17 +11,17 @@ export function HomeSessions(props: {
return ( return (
<HomeSessionsView <HomeSessionsView
language={props.sessions.copy.language} language={props.sessions.copy.language}
groups={props.sessions.data.groups()} groups={props.sessions.data.groups}
showProjectName={props.sessions.session.showProjectName()} showProjectName={props.sessions.session.showProjectName}
server={props.sessions.session.server()} server={props.sessions.session.server}
canCreateSession={props.sessions.session.canCreate()} canCreateSession={props.sessions.session.canCreate}
searchValue={props.search.query.value()} searchValue={props.search.query.value}
searchPlaceholder={props.search.query.placeholder()} searchPlaceholder={props.search.query.placeholder}
searchOpen={props.search.query.open()} searchOpen={props.search.query.open}
searchLoading={props.search.result.loading()} searchLoading={props.search.result.loading}
searchResults={props.search.result.list()} searchResults={props.search.result.list}
searchActive={props.search.result.active()} searchActive={props.search.result.active}
searchNoResultsLabel={props.search.result.noResultsLabel()} searchNoResultsLabel={props.search.result.noResultsLabel}
titleOpacity={props.scroll.header.titleOpacity} titleOpacity={props.scroll.header.titleOpacity}
isOpenTab={props.sessions.tab.isOpen} isOpenTab={props.sessions.tab.isOpen}
onCreateSession={props.sessions.session.create} onCreateSession={props.sessions.session.create}
+6 -10
View File
@@ -1,4 +1,4 @@
import { onMount, Show, Suspense, type ParentProps } from "solid-js" import { createEffect, Suspense, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { DebugBar } from "@/components/debug-bar" import { DebugBar } from "@/components/debug-bar"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
@@ -9,17 +9,15 @@ export default function Layout(props: ParentProps) {
const platform = usePlatform() const platform = usePlatform()
const [state, setState] = createStore({ debugTools: true }) const [state, setState] = createStore({ debugTools: true })
onMount(() => setV2Toast(true)) createEffect(() => setV2Toast(true))
const update: TitlebarUpdate = { const update: TitlebarUpdate = {
get version() { version: () => {
const state = platform.updater?.state() const state = platform.updater?.state()
if (state?.status !== "ready") return undefined if (state?.status !== "ready") return
return state.version return state.version
}, },
get installing() { installing: () => platform.updater?.state().status === "installing",
return platform.updater?.state().status === "installing"
},
install: () => void platform.updater?.install(), install: () => void platform.updater?.install(),
} }
@@ -42,9 +40,7 @@ export default function Layout(props: ParentProps) {
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict"> <main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Suspense>{props.children}</Suspense> <Suspense>{props.children}</Suspense>
</main> </main>
<Show when={import.meta.env.DEV && state.debugTools}> {import.meta.env.DEV && state.debugTools && <DebugBar inline />}
<DebugBar inline />
</Show>
<ToastRegion v2 /> <ToastRegion v2 />
</div> </div>
) )
@@ -2,7 +2,7 @@ import { createMemo, type Accessor } from "solid-js"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { sessionPermissionRequest, sessionQuestionForm } from "@/pages/session/composer/session-request-tree" import { sessionPermissionRequest, sessionQuestionRequest } from "@/pages/session/composer/session-request-tree"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
export function useSessionTabAvatarState( export function useSessionTabAvatarState(
@@ -31,7 +31,7 @@ export function useSessionTabAvatarState(
const serverSync = sync() const serverSync = sync()
if (!serverSync) return false if (!serverSync) return false
const [store] = serverSync.child(directory(), { bootstrap: false }) const [store] = serverSync.child(directory(), { bootstrap: false })
return !!sessionQuestionForm(store.session, serverSync.session.data.form, sessionId()) return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId())
}) })
const needsAttention = createMemo(() => hasPermissions() || hasQuestions()) const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
const notificationState = createMemo(() => { const notificationState = createMemo(() => {

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