mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 20:50:01 -04:00
Compare commits
102 Commits
title-wipe
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
| 476cb4bc99 | |||
| dfb82be814 | |||
| 69dc6c7e39 | |||
| af9f54fc3a | |||
| 0505815278 | |||
| 615e8ffb91 | |||
| a2ed936283 | |||
| b17fbf41e3 | |||
| 9d6e05b6e4 | |||
| 9b805c140f | |||
| d31a994c27 | |||
| 76640a5c9c | |||
| d101da90f7 | |||
| 621a257ea8 | |||
| 76dbaf20ad | |||
| 9dd0e39867 | |||
| 69a465d33b | |||
| 66c2967520 | |||
| 883c9e3cb4 | |||
| 0d703d39e7 | |||
| bc2251d6d8 | |||
| b034154e09 | |||
| c894f87771 | |||
| fc5c781085 | |||
| f6aa1a67f0 | |||
| 0859f77153 | |||
| 7a22ac865d | |||
| b75dd58f7c | |||
| 56197e621a | |||
| d8f62cfdcb | |||
| c9539979bd | |||
| f5e6b8c769 | |||
| 2025ed939a | |||
| a7642737c7 | |||
| c081e12c60 | |||
| 8d5dd206a6 | |||
| c6a86acb0b | |||
| c7bee09632 | |||
| a3625cd2cb | |||
| 0dec446ee6 | |||
| dff6eb631b | |||
| 3251bff6ff | |||
| f713d39c61 | |||
| c80472b6d5 | |||
| c86b3079ac | |||
| f8f50ee375 | |||
| 912a1c4e8a | |||
| a841fc22bb | |||
| 723f5e0028 | |||
| 17cae23dce | |||
| fd30b9765d | |||
| a20d945245 | |||
| 10ebf70a07 | |||
| b50924b993 | |||
| b24b1b3f16 | |||
| 930b0751b1 | |||
| f06a86eeac | |||
| 653b7d79cd | |||
| 70ce0d0970 | |||
| 0777e84598 | |||
| bef795b2fe | |||
| 70853b1e5b | |||
| 1fea1c2ebc | |||
| 1da591b84d | |||
| b990f9a5c1 | |||
| 9769e7012c | |||
| 5fd28cffc5 | |||
| c8dffb6893 | |||
| 7674941aad | |||
| c2777d4733 | |||
| dcfda1958c | |||
| 3e77c2d69a | |||
| 5b160c1232 | |||
| 01cbdcb8e4 | |||
| bf751a907d | |||
| bfb76792b7 | |||
| 59a6315b4d | |||
| 9d63ca8f90 | |||
| e3bd82013c | |||
| 90b6fa0eab | |||
| 5b7b1830d2 | |||
| a8fc664b6d | |||
| d853ff8848 | |||
| 079a50d80a | |||
| 94bc0fc6fa | |||
| d3eecf7ba2 | |||
| 5193c98c3e | |||
| 6819de7d04 | |||
| fca453a2a5 | |||
| 2a51cf5f88 | |||
| b7bd2b0ae0 | |||
| 45b2759a1e | |||
| 65b6abc4bb | |||
| 8c8492ef97 | |||
| 115e689723 | |||
| 7c30a4230e | |||
| bd4557291b | |||
| 9ecf46bfea | |||
| 9ded81b6c2 | |||
| 87142c1b4c | |||
| d8320209c9 | |||
| 31c72b9a17 |
@@ -10,6 +10,7 @@ kitlangton
|
||||
kommander
|
||||
ludvigrask
|
||||
MrMushrooooom
|
||||
neriousy
|
||||
nexxeln
|
||||
R44VC0RP
|
||||
rekram1-node
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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 }}
|
||||
@@ -55,6 +55,12 @@ jobs:
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
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
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
@@ -66,7 +72,7 @@ jobs:
|
||||
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: GITHUB_ACTIONS=false bun turbo test
|
||||
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }}
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
|
||||
@@ -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,
|
||||
and `fs.writeFile` always writes inside the simulated project.
|
||||
|
||||
You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
|
||||
You can read the full typed API here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/src/script/types.ts
|
||||
|
||||
```ts
|
||||
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
|
||||
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
|
||||
here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
|
||||
here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/multiple-clients.ts
|
||||
|
||||
Use the exported `wait(milliseconds)` utility for an unconditional delay.
|
||||
|
||||
@@ -114,8 +114,8 @@ completion are automatic.
|
||||
|
||||
You can see some example scripts here:
|
||||
|
||||
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
|
||||
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
|
||||
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/simple.ts
|
||||
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/serve.ts
|
||||
|
||||
## Prune
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
|
||||
const TEAM = {
|
||||
tui: ["kommander", "simonklee"],
|
||||
desktop_web: ["Hona", "Brendonovich"],
|
||||
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
|
||||
core: ["jlongster", "rekram1-node", "neriousy", "nexxeln", "kitlangton"],
|
||||
inference: ["fwang", "MrMushrooooom", "starptech"],
|
||||
windows: ["Hona"],
|
||||
} as const
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
- 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.
|
||||
- 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 `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
- The default branch in this repo is `v2`.
|
||||
- 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 `v2` or `origin/v2` for diffs.
|
||||
|
||||
## Live V2 TUI Testing
|
||||
|
||||
|
||||
+10
-7
@@ -33,6 +33,7 @@
|
||||
"packages": [
|
||||
"packages/*",
|
||||
"packages/console/*",
|
||||
"packages/lab/*",
|
||||
"packages/stats/*",
|
||||
"packages/slack"
|
||||
],
|
||||
@@ -46,9 +47,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/core": "0.5.2",
|
||||
"@opentui/keymap": "0.5.2",
|
||||
"@opentui/solid": "0.5.2",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
@@ -120,7 +121,8 @@
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "catalog:",
|
||||
"turbo": "2.10.2"
|
||||
"turbo": "2.10.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
@@ -150,9 +152,9 @@
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:"
|
||||
@@ -172,6 +174,7 @@
|
||||
"@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",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,7 +573,10 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
cache_control: cacheControl(breakpoints, part.cache),
|
||||
})
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
const previous = messages.at(-1)
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Stream } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
Headers,
|
||||
@@ -297,44 +297,86 @@ export const classifyHttpFailure = (input: {
|
||||
})
|
||||
}
|
||||
|
||||
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
|
||||
const transportError = (input: {
|
||||
readonly message: string
|
||||
readonly kind?: string | undefined
|
||||
readonly request?: HttpClientRequest.HttpClientRequest | undefined
|
||||
}) =>
|
||||
type HttpOperation = "request" | "read"
|
||||
|
||||
const NativeTransportFailure = Schema.Struct({
|
||||
message: Schema.String,
|
||||
code: Schema.optionalKey(Schema.String),
|
||||
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({
|
||||
module: "RequestExecutor",
|
||||
method: "execute",
|
||||
method: input.operation,
|
||||
reason: new TransportReason({
|
||||
message: input.message,
|
||||
kind: input.kind,
|
||||
url: input.request ? redactUrl(input.request.url) : undefined,
|
||||
http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
|
||||
message: failure.message,
|
||||
transport: "http",
|
||||
operation: input.operation,
|
||||
code: failure.code,
|
||||
url: redactUrl(request.url),
|
||||
http: new HttpContext({ request: requestDetails(request, input.redactedNames) }),
|
||||
}),
|
||||
})
|
||||
|
||||
if (Cause.isTimeoutError(error)) {
|
||||
return transportError({ message: error.message, kind: "Timeout" })
|
||||
}
|
||||
if (!HttpClientError.isHttpClientError(error)) {
|
||||
return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" })
|
||||
}
|
||||
const request = "request" in error ? error.request : undefined
|
||||
if (error.reason._tag === "TransportError") {
|
||||
const source =
|
||||
HttpClientError.isHttpClientError(input.error) && "cause" in input.error.reason
|
||||
? input.error.reason.cause
|
||||
: input.error
|
||||
const native = nativeTransportFailure(source)
|
||||
const code = native?.code
|
||||
const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined)
|
||||
const detail = raw ? redactBody(raw, secretValues(request)) : undefined
|
||||
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({
|
||||
message: error.reason.description ?? "HTTP transport failed",
|
||||
kind: error.reason._tag,
|
||||
request,
|
||||
message: message ?? input.error.reason.description ?? "HTTP transport failed",
|
||||
code: code ?? input.error.reason._tag,
|
||||
})
|
||||
}
|
||||
return transportError({
|
||||
message: `HTTP transport failed: ${error.reason._tag}`,
|
||||
kind: error.reason._tag,
|
||||
request,
|
||||
message: message ?? `HTTP transport failed: ${input.error.reason._tag}`,
|
||||
code: code ?? input.error.reason._tag,
|
||||
})
|
||||
}
|
||||
|
||||
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(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -343,15 +385,16 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
|
||||
Effect.gen(function* () {
|
||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
||||
if (!middleware)
|
||||
return yield* http
|
||||
.execute(request)
|
||||
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
||||
return yield* http.execute(request).pipe(
|
||||
Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })),
|
||||
Effect.flatMap(statusError(request, redactedNames)),
|
||||
)
|
||||
|
||||
const response = yield* middleware(request, (input) =>
|
||||
http
|
||||
.execute(input)
|
||||
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||
).pipe(Effect.mapError(toHttpError(redactedNames)))
|
||||
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })))
|
||||
return yield* statusError(response.request, redactedNames)(response)
|
||||
})
|
||||
return Service.of({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Auth } from "../auth.js"
|
||||
import { render as renderEndpoint } from "../endpoint.js"
|
||||
@@ -6,6 +6,7 @@ import { Framing } from "../framing.js"
|
||||
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index.js"
|
||||
import * as ProviderShared from "../../protocols/shared.js"
|
||||
import { mergeJsonRecords, type LLMRequest } from "../../schema/index.js"
|
||||
import { RequestExecutor } from "../executor.js"
|
||||
|
||||
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
|
||||
|
||||
@@ -86,26 +87,8 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
middleware: prepareInput.middleware,
|
||||
}
|
||||
}),
|
||||
frames: (prepared, request, runtime) =>
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
frames: (prepared, _request, runtime) =>
|
||||
prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
|
||||
})
|
||||
|
||||
export const sseJson = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { AIError, TransportReason } from "../../schema/index.js"
|
||||
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
|
||||
import * as HttpTransport from "./http.js"
|
||||
import type { Transport } from "./index.js"
|
||||
|
||||
@@ -29,12 +29,18 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AI
|
||||
const transportError = (
|
||||
method: string,
|
||||
message: string,
|
||||
input: { readonly url?: string; readonly kind?: string } = {},
|
||||
input: { readonly operation: TransportOperation; readonly url?: string; readonly code?: string },
|
||||
) =>
|
||||
new AIError({
|
||||
module: "WebSocketExecutor",
|
||||
method,
|
||||
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
|
||||
reason: new TransportReason({
|
||||
message,
|
||||
transport: "websocket",
|
||||
operation: input.operation,
|
||||
url: input.url,
|
||||
code: input.code,
|
||||
}),
|
||||
})
|
||||
|
||||
const eventMessage = (event: Event) => {
|
||||
@@ -55,7 +61,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
return Effect.fail(
|
||||
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
|
||||
url: input.url,
|
||||
kind: "open",
|
||||
operation: "request",
|
||||
code: "closed",
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -79,7 +86,10 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
cleanup()
|
||||
resume(
|
||||
Effect.fail(
|
||||
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { url: input.url, kind: "open" }),
|
||||
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -89,7 +99,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
Effect.fail(
|
||||
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
|
||||
url: input.url,
|
||||
kind: "open",
|
||||
operation: "request",
|
||||
code: String(event.code),
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -118,7 +129,8 @@ const webSocketUrl = (value: string) =>
|
||||
catch: (error) =>
|
||||
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
|
||||
url: value,
|
||||
kind: "websocket",
|
||||
operation: "request",
|
||||
code: "invalid-url",
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -129,7 +141,7 @@ export const open = (input: WebSocketRequest) =>
|
||||
catch: (error) =>
|
||||
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||
url: input.url,
|
||||
kind: "open",
|
||||
operation: "request",
|
||||
}),
|
||||
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
|
||||
|
||||
@@ -150,7 +162,10 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", "Unsupported WebSocket message payload", { url: input.url, kind: "message" }),
|
||||
transportError("message", "Unsupported WebSocket message payload", {
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -158,7 +173,10 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", `WebSocket error: ${eventMessage(event)}`, { url: input.url, kind: "message" }),
|
||||
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -167,7 +185,11 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", `WebSocket closed with code ${event.code}`, { url: input.url, kind: "close" }),
|
||||
transportError("message", `WebSocket closed with code ${event.code}`, {
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: String(event.code),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -188,7 +210,7 @@ export const fromWebSocket = (
|
||||
catch: (error) =>
|
||||
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
||||
url: input.url,
|
||||
kind: "write",
|
||||
operation: "write",
|
||||
}),
|
||||
}),
|
||||
messages: Stream.fromQueue(messages),
|
||||
@@ -243,7 +265,8 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
||||
return Stream.fail(
|
||||
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
|
||||
url: prepared.url,
|
||||
kind: "websocket",
|
||||
operation: "request",
|
||||
code: "unavailable",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,10 +92,18 @@ export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>
|
||||
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")({
|
||||
_tag: Schema.tag("Transport"),
|
||||
message: Schema.String,
|
||||
kind: Schema.optional(Schema.String),
|
||||
transport: TransportType,
|
||||
operation: TransportOperation,
|
||||
code: Schema.optional(Schema.String),
|
||||
url: Schema.optional(Schema.String),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Ref } from "effect"
|
||||
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Effect, Layer, Ref, Stream } from "effect"
|
||||
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLM, AIError } from "../src/index.js"
|
||||
import { LLMClient, RequestExecutor } from "../src/route.js"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||
import { dynamicResponse } from "./lib/http.js"
|
||||
import { dynamicResponse, systemError } from "./lib/http.js"
|
||||
import { deltaChunk } from "./lib/openai-chunks.js"
|
||||
import { sseRaw } from "./lib/sse.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
@@ -67,6 +67,62 @@ const expectAIError = (error: unknown) => {
|
||||
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
|
||||
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
@@ -79,6 +135,48 @@ describe("RequestExecutor", () => {
|
||||
}).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", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Layer, Ref } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js"
|
||||
import type { Service as LLMClientService } from "../../src/route/client.js"
|
||||
import type { Service as RequestExecutorService } from "../../src/route/executor.js"
|
||||
@@ -14,7 +14,9 @@ export type HandlerInput = {
|
||||
) => HttpClientResponse.HttpClientResponse
|
||||
}
|
||||
|
||||
export type Handler = (input: HandlerInput) => Effect.Effect<HttpClientResponse.HttpClientResponse>
|
||||
export type Handler = (
|
||||
input: HandlerInput,
|
||||
) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>
|
||||
|
||||
const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
|
||||
Layer.succeed(
|
||||
@@ -34,6 +36,12 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
|
||||
|
||||
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> => {
|
||||
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
|
||||
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
||||
@@ -63,14 +71,20 @@ export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(h
|
||||
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
|
||||
* exercise transport errors that surface during parsing.
|
||||
*/
|
||||
export const truncatedStream = (chunks: ReadonlyArray<string>) =>
|
||||
export const truncatedStream = (chunks: ReadonlyArray<string>, error: Error = new Error("connection reset")) =>
|
||||
dynamicResponse((input) =>
|
||||
Effect.sync(() => {
|
||||
const encoder = new TextEncoder()
|
||||
let index = 0
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
|
||||
controller.error(new Error("connection reset"))
|
||||
pull(controller) {
|
||||
const chunk = chunks[index]
|
||||
if (chunk !== undefined) {
|
||||
index++
|
||||
controller.enqueue(encoder.encode(chunk))
|
||||
return
|
||||
}
|
||||
controller.error(error)
|
||||
},
|
||||
})
|
||||
return input.respond(stream, { headers: SSE_HEADERS })
|
||||
|
||||
@@ -271,6 +271,47 @@ 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", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -915,6 +956,54 @@ 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", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Effect, Ref, Schema, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
HttpOptions,
|
||||
@@ -22,7 +22,7 @@ import { ProviderShared } from "../../src/protocols/shared.js"
|
||||
import { Auth, LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http.js"
|
||||
import { dynamicResponse, fixedResponse, systemError, truncatedStream } from "../lib/http.js"
|
||||
import { deltaChunk, usageChunk } from "../lib/openai-chunks.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
@@ -1221,12 +1221,44 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("surfaces transport errors that occur mid-stream", () =>
|
||||
Effect.gen(function* () {
|
||||
const layer = truncatedStream([
|
||||
`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`,
|
||||
])
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
|
||||
const layer = truncatedStream(
|
||||
[`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`],
|
||||
systemError("ECONNRESET", "socket closed unexpectedly"),
|
||||
)
|
||||
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(error.message).toContain("Failed to read openai/openai-chat stream")
|
||||
expect((yield* Ref.get(events)).some((event) => event.type === "text-delta")).toBeTrue()
|
||||
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",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
## Localization
|
||||
|
||||
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
|
||||
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
|
||||
- Render count-sensitive copy only through `language.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `language.t(...)`.
|
||||
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
||||
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
||||
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
|
||||
|
||||
@@ -122,6 +122,7 @@ test("applies message latency after a list response gate is released", async ()
|
||||
const gate = Promise.withResolvers<void>()
|
||||
let handler: ((route: Route) => Promise<void>) | undefined
|
||||
const page = {
|
||||
addInitScript: () => Promise.resolve(),
|
||||
route: (_url: string, callback: (route: Route) => Promise<void>) => {
|
||||
handler = callback
|
||||
return Promise.resolve()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
@@ -78,6 +79,8 @@ function session(id: string, directory: string, title: string) {
|
||||
}
|
||||
|
||||
async function mockServers(page: Page, requests: string[]) {
|
||||
await installSseTransport(page, { server: serverA })
|
||||
await installSseTransport(page, { server: serverB })
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||
@@ -85,7 +88,6 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
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/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
@@ -142,7 +144,3 @@ function json(route: Route, body: unknown, status = 200) {
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ const sessionB = session("ses_server_b", directoryB, "Server B session")
|
||||
|
||||
test("session settings use the remote server context", async ({ page }) => {
|
||||
const permissionRequests: string[] = []
|
||||
await installSseTransport(page, { server: serverA })
|
||||
await installSseTransport(page, { server: serverB })
|
||||
await mockServers(page, permissionRequests)
|
||||
await configureServers(page)
|
||||
|
||||
@@ -46,6 +48,7 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
test("auto-accept responds for an unfocused server session", async ({ page }) => {
|
||||
const permissionRequests: string[] = []
|
||||
const permissionResponses: PermissionResponse[] = []
|
||||
await installSseTransport(page, { server: serverB })
|
||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
||||
server: serverA,
|
||||
retry: 20,
|
||||
@@ -181,7 +184,6 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
return json(route, true)
|
||||
}
|
||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/event") return sse(route)
|
||||
if (url.pathname === "/api/provider")
|
||||
return json(route, {
|
||||
location: { directory },
|
||||
@@ -325,7 +327,3 @@ function json(route: Route, body: unknown, status = 200) {
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||
}
|
||||
|
||||
@@ -11,22 +11,28 @@ const title = "Request dock regression"
|
||||
|
||||
test("shows a pending question dock", async ({ page }) => {
|
||||
await mockServer(page, {
|
||||
questions: [
|
||||
forms: [
|
||||
{
|
||||
id: "question-request",
|
||||
id: "frm_question_request",
|
||||
sessionID,
|
||||
questions: [
|
||||
title: "Questions",
|
||||
metadata: { kind: "question" },
|
||||
fields: [
|
||||
{
|
||||
header: "Implementation",
|
||||
question: "Which implementation should be used?",
|
||||
key: "q0",
|
||||
type: "string",
|
||||
title: "Implementation",
|
||||
description: "Which implementation should be used?",
|
||||
options: [
|
||||
{ label: "Minimal", description: "Use the smallest correct change" },
|
||||
{ label: "Extended", description: "Include additional behavior" },
|
||||
{ value: "minimal", label: "Minimal", description: "Use the smallest correct change" },
|
||||
{ value: "extended", label: "Extended", description: "Include additional behavior" },
|
||||
],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sessionStatus: { [sessionID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
@@ -42,7 +48,7 @@ test("shows a pending question dock", async ({ page }) => {
|
||||
const rejectRequests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
|
||||
if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`)
|
||||
rejectRequests.push(request.url())
|
||||
})
|
||||
|
||||
@@ -67,10 +73,10 @@ test("shows a pending question dock", async ({ page }) => {
|
||||
const reply = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
|
||||
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/reply`,
|
||||
)
|
||||
await question.getByRole("button", { name: "Submit" }).click()
|
||||
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
||||
expect((await reply).postDataJSON()).toEqual({ answer: { q0: "minimal" } })
|
||||
})
|
||||
|
||||
test("shows a pending permission dock", async ({ page }) => {
|
||||
@@ -109,9 +115,9 @@ 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"}`,
|
||||
retry: 20,
|
||||
})
|
||||
await mockServer(page, { questions: [] })
|
||||
await mockServer(page, { forms: [] })
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await transport.waitForConnection({ path: "/api/event" })
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
||||
@@ -135,28 +141,39 @@ test("restores the draft caret before typing after a request dock closes", async
|
||||
await transport.send({
|
||||
directory,
|
||||
payload: {
|
||||
type: "question.asked",
|
||||
type: "form.created",
|
||||
properties: {
|
||||
id: "question-caret",
|
||||
sessionID,
|
||||
questions: [
|
||||
{
|
||||
header: "Continue",
|
||||
question: "Continue?",
|
||||
options: [{ label: "Yes", description: "Continue the session" }],
|
||||
},
|
||||
],
|
||||
tool: { messageID: "message-caret", callID: "call-caret" },
|
||||
form: {
|
||||
id: "frm_question_caret",
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } },
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
type: "string",
|
||||
title: "Continue",
|
||||
description: "Continue?",
|
||||
options: [{ value: "yes", label: "Yes", description: "Continue the session" }],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
undefined,
|
||||
"/api/event",
|
||||
)
|
||||
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
await expect(question).toBeVisible()
|
||||
await expect(editor).toHaveCount(0)
|
||||
|
||||
await transport.send({
|
||||
directory,
|
||||
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
||||
payload: {
|
||||
type: "form.cancelled",
|
||||
properties: { sessionID, id: "frm_question_caret" },
|
||||
},
|
||||
})
|
||||
await expect(question).toHaveCount(0)
|
||||
await expect(editor).toBeVisible()
|
||||
@@ -170,6 +187,8 @@ async function mockServer(
|
||||
requests: {
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
forms?: unknown[] | (() => unknown[])
|
||||
sessionStatus?: Record<string, unknown>
|
||||
},
|
||||
) {
|
||||
await mockOpenCodeServer(page, {
|
||||
@@ -214,6 +233,8 @@ async function mockServer(
|
||||
pageMessages: () => ({ items: [] }),
|
||||
permissions: requests.permissions,
|
||||
questions: requests.questions,
|
||||
forms: requests.forms,
|
||||
sessionStatus: requests.sessionStatus,
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
|
||||
@@ -17,7 +17,7 @@ import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const initialPageSize = 20
|
||||
const historyPageSize = 200
|
||||
const historyPageSize = 50
|
||||
const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
|
||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||
return [
|
||||
|
||||
@@ -89,13 +89,15 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||
|
||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
id: "timeline-event-7",
|
||||
})
|
||||
const first = await timeline.transport.send(
|
||||
partUpdated(textPart("prt_transport_id", "event with id")),
|
||||
{ id: "timeline-event-7" },
|
||||
"/api/event",
|
||||
)
|
||||
await timeline.waitForPart("prt_transport_id")
|
||||
|
||||
await timeline.transport.error("retry with event id")
|
||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
||||
await timeline.transport.error("retry with event id", "/api/event")
|
||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID, path: "/api/event" })
|
||||
|
||||
expect(first.eventID).toBe("timeline-event-7")
|
||||
expect(connection.headers["last-event-id"]).toBeUndefined()
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import type { OpenCodeEvent, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const root = "C:/OpenCode/WorkspaceProject"
|
||||
const workspace = "C:/OpenCode/worktree/project/feature"
|
||||
const createdWorkspace = "C:/OpenCode/worktree/project/quick-contrast-fix"
|
||||
const project = {
|
||||
id: "proj_workspaces",
|
||||
canonical: root,
|
||||
vcs: "git" as const,
|
||||
name: "workspace-project",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [workspace],
|
||||
}
|
||||
const provider = {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test model", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
}
|
||||
const diff = {
|
||||
file: "src/workspace.ts",
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
status: "modified" as const,
|
||||
patch: "@@ -1 +1 @@\n-export const workspace = false\n+export const workspace = true",
|
||||
}
|
||||
const cors = {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
|
||||
"access-control-allow-headers": "content-type",
|
||||
}
|
||||
|
||||
function session(id: string, directory: string, title?: string): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
projectID: project.id,
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", id: "test" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
title,
|
||||
location: { directory },
|
||||
subpath: "",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
}
|
||||
|
||||
function userMessage(id: string, text: string) {
|
||||
return { id, type: "user" as const, time: { created: 1 }, text }
|
||||
}
|
||||
|
||||
async function json(route: Route, body: unknown) {
|
||||
await route.fulfill({ status: 200, contentType: "application/json", headers: cors, body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
async function init(page: Page, tab: Record<string, unknown>) {
|
||||
await page.addInitScript(
|
||||
({ root, server, tab }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: root, expanded: true }] }, lastProject: { local: root } }),
|
||||
)
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ server, ...tab }]))
|
||||
},
|
||||
{ root, server, tab },
|
||||
)
|
||||
}
|
||||
|
||||
test("selects an existing workspace from the start menu", async ({ page }) => {
|
||||
const draftID = "draft_workspaces"
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await init(page, { type: "draft", draftID, directory: root })
|
||||
const directories = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "GET" &&
|
||||
new URL(request.url()).pathname === `/api/project/${project.id}/directories`,
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await directories
|
||||
await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
|
||||
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Workspace" }).hover()
|
||||
await page.getByRole("menuitem", { name: "feature", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "feature", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("lists and manually deletes workspaces from settings", async ({ page }) => {
|
||||
const draftID = "draft_workspace_settings"
|
||||
const cleanWorkspace = `${workspace}-clean`
|
||||
const inventory = { ...project, sandboxes: [cleanWorkspace] }
|
||||
let releaseSessions = () => {}
|
||||
const sessionsReady = new Promise<void>((resolve) => {
|
||||
releaseSessions = resolve
|
||||
})
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory: root,
|
||||
project: inventory,
|
||||
provider,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/api/session**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (
|
||||
route.request().method() !== "GET" ||
|
||||
url.pathname !== "/api/session" ||
|
||||
url.searchParams.get("limit") !== "100" ||
|
||||
url.searchParams.get("order") !== "desc"
|
||||
)
|
||||
return route.fallback()
|
||||
await sessionsReady
|
||||
await json(route, { data: [], cursor: {} })
|
||||
})
|
||||
await init(page, { type: "draft", draftID, directory: root })
|
||||
const directories = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "GET" &&
|
||||
new URL(request.url()).pathname === `/api/project/${project.id}/directories`,
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await directories
|
||||
await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
|
||||
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Workspace" }).hover()
|
||||
const sessions = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "GET" &&
|
||||
new URL(request.url()).pathname === "/api/session" &&
|
||||
new URL(request.url()).searchParams.get("limit") === "100",
|
||||
)
|
||||
await page.getByRole("menuitem", { name: "View all", exact: true }).click()
|
||||
await sessions
|
||||
|
||||
const settings = page.getByRole("dialog")
|
||||
await expect(settings.getByRole("tab", { name: "Workspaces" })).toHaveAttribute("data-selected")
|
||||
await expect(page.locator('[data-component="session-new-design"]')).toBeAttached()
|
||||
releaseSessions()
|
||||
await expect(settings.getByLabel(cleanWorkspace, { exact: true })).toBeVisible()
|
||||
|
||||
await settings.getByRole("button", { name: 'Delete workspace "feature-clean"?' }).click()
|
||||
const confirmation = page.getByRole("dialog").filter({ hasText: 'Delete workspace "feature-clean"?' })
|
||||
const removed = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "DELETE" &&
|
||||
new URL(request.url()).pathname === `/experimental/project/${project.id}/copy`,
|
||||
)
|
||||
await confirmation.getByRole("button", { name: "Delete workspace", exact: true }).click()
|
||||
const request = await removed
|
||||
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(root)
|
||||
expect(request.postDataJSON()).toEqual({ directory: cleanWorkspace, force: true })
|
||||
await expect(settings.getByLabel(cleanWorkspace, { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("submits the owning prompt after a new workspace is created", async ({ page }) => {
|
||||
const draftID = "draft_workspace_submit"
|
||||
const sessionID = "ses_workspace_submit"
|
||||
const createdSession = session(sessionID, createdWorkspace)
|
||||
let releaseCopy = () => {}
|
||||
const copyReady = new Promise<void>((resolve) => {
|
||||
releaseCopy = resolve
|
||||
})
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route(`**/experimental/project/${project.id}/copy**`, async (route) => {
|
||||
const request = route.request()
|
||||
if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers: cors })
|
||||
if (request.method() !== "POST") return route.fallback()
|
||||
await copyReady
|
||||
await json(route, { directory: createdWorkspace })
|
||||
})
|
||||
await page.route("**/api/session**", async (route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const promptPath = `/api/session/${sessionID}/prompt`
|
||||
if (request.method() === "OPTIONS" && (url.pathname === "/api/session" || url.pathname === promptPath))
|
||||
return route.fulfill({ status: 204, headers: cors })
|
||||
if (request.method() === "POST" && url.pathname === "/api/session")
|
||||
return json(route, { data: createdSession })
|
||||
if (request.method() === "GET" && url.pathname === `/api/session/${sessionID}`)
|
||||
return json(route, { data: createdSession })
|
||||
if (request.method() !== "POST" || url.pathname !== promptPath) return route.fallback()
|
||||
const input = request.postDataJSON() as { id: string; text: string }
|
||||
await json(route, {
|
||||
data: {
|
||||
id: input.id,
|
||||
sessionID,
|
||||
timeCreated: 3,
|
||||
type: "user",
|
||||
data: { text: input.text },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
})
|
||||
await init(page, { type: "draft", draftID, directory: root })
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
const editor = page.getByRole("textbox", { name: "Prompt" })
|
||||
await expectAppVisible(editor)
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
|
||||
await editor.fill("Build workspace support")
|
||||
|
||||
const copied = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname === `/experimental/project/${project.id}/copy`,
|
||||
)
|
||||
const created = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/api/session",
|
||||
)
|
||||
const sent = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
|
||||
)
|
||||
await page.getByRole("button", { name: "Send", exact: true }).click()
|
||||
|
||||
const copyRequest = await copied
|
||||
expect(new URL(copyRequest.url()).searchParams.get("location[directory]")).toBe(root)
|
||||
expect(copyRequest.postDataJSON()).toEqual({ strategy: "git_worktree", directory: "C:/OpenCode" })
|
||||
releaseCopy()
|
||||
|
||||
expect((await created).postDataJSON()).toEqual({
|
||||
agent: "build",
|
||||
model: { id: "test", providerID: "opencode" },
|
||||
location: { directory: createdWorkspace },
|
||||
})
|
||||
const promptRequest = await sent
|
||||
expect(promptRequest.postDataJSON()).toEqual({
|
||||
id: expect.stringMatching(/^msg_/),
|
||||
text: "Build workspace support",
|
||||
files: [],
|
||||
agents: [],
|
||||
})
|
||||
await expect(page.getByText("Workspace created", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("moves a changed local session through workspace creation without changing lifecycle semantics", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sessionID = "ses_workspace_move_new"
|
||||
const messageID = "msg_workspace_move_new"
|
||||
const currentSession = session(sessionID, root, "Create a workspace")
|
||||
const transport = await installSseTransport<OpenCodeEvent>(page, { server })
|
||||
let releaseCopy = () => {}
|
||||
const copyReady = new Promise<void>((resolve) => {
|
||||
releaseCopy = resolve
|
||||
})
|
||||
let releaseMove = () => {}
|
||||
const moveReady = new Promise<void>((resolve) => {
|
||||
releaseMove = resolve
|
||||
})
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [currentSession],
|
||||
pageMessages: () => ({ items: [userMessage(messageID, "Create isolated workspace")] }),
|
||||
vcsDiff: [diff],
|
||||
})
|
||||
await page.route(`**/experimental/project/${project.id}/copy**`, async (route) => {
|
||||
const request = route.request()
|
||||
if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers: cors })
|
||||
if (request.method() !== "POST") return route.fallback()
|
||||
await copyReady
|
||||
await json(route, { directory: createdWorkspace })
|
||||
})
|
||||
await page.route(`**/api/session/${sessionID}**`, async (route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
if (request.method() === "OPTIONS" && url.pathname === `/api/session/${sessionID}/move`)
|
||||
return route.fulfill({ status: 204, headers: cors })
|
||||
if (request.method() === "GET" && url.pathname === `/api/session/${sessionID}`)
|
||||
return json(route, { data: currentSession })
|
||||
if (request.method() !== "POST" || url.pathname !== `/api/session/${sessionID}/move`)
|
||||
return route.fallback()
|
||||
await moveReady
|
||||
currentSession.location.directory = createdWorkspace
|
||||
await route.fulfill({ status: 204, headers: cors })
|
||||
})
|
||||
await init(page, { type: "session", sessionId: sessionID })
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Local repository", exact: true }).click()
|
||||
|
||||
const copied = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname === `/experimental/project/${project.id}/copy`,
|
||||
)
|
||||
const moved = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/move`,
|
||||
)
|
||||
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
|
||||
|
||||
await copied
|
||||
await expect(page.getByText("Creating workspace", { exact: true })).toBeVisible()
|
||||
releaseCopy()
|
||||
|
||||
const moveRequest = await moved
|
||||
expect(moveRequest.postDataJSON()).toEqual({ directory: createdWorkspace })
|
||||
await transport.send({
|
||||
id: "evt_workspace_created",
|
||||
created: 3,
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
location: { directory: root },
|
||||
data: {
|
||||
sessionID,
|
||||
location: { directory: createdWorkspace },
|
||||
subpath: "",
|
||||
},
|
||||
})
|
||||
releaseMove()
|
||||
await expect(page.getByText("Workspace created", { exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -189,7 +189,12 @@ test.describe("smoke: session timeline", () => {
|
||||
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
||||
?.getBoundingClientRect()
|
||||
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
|
||||
if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
|
||||
if (
|
||||
!firstPaint &&
|
||||
visible.includes(last) &&
|
||||
Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1 &&
|
||||
!root.querySelector('[data-markdown-key="initial"]')
|
||||
) {
|
||||
firstPaint = true
|
||||
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
@@ -204,10 +209,16 @@ test.describe("smoke: session timeline", () => {
|
||||
}
|
||||
;(
|
||||
window as Window & {
|
||||
__sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
|
||||
__sessionTabPaint?: {
|
||||
samples: typeof samples
|
||||
painted: () => boolean
|
||||
removed: () => number
|
||||
stop: () => void
|
||||
}
|
||||
}
|
||||
).__sessionTabPaint = {
|
||||
samples,
|
||||
painted: () => firstPaint,
|
||||
removed: () => removedFirstPaintNodes,
|
||||
stop: () => {
|
||||
running = false
|
||||
@@ -219,17 +230,19 @@ test.describe("smoke: session timeline", () => {
|
||||
)
|
||||
|
||||
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||
await page.waitForFunction(() =>
|
||||
(
|
||||
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
|
||||
).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
|
||||
)
|
||||
await page.waitForFunction(() => {
|
||||
const probe = (
|
||||
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }>; painted: () => boolean } }
|
||||
).__sessionTabPaint
|
||||
return probe?.painted() && probe.samples.some((sample) => sample.ids.length > 0)
|
||||
})
|
||||
await page.waitForTimeout(200)
|
||||
const first = await page.evaluate(() => {
|
||||
const probe = (
|
||||
window as Window & {
|
||||
__sessionTabPaint?: {
|
||||
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
|
||||
painted: () => boolean
|
||||
removed: () => number
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"../src/pages/session/timeline/observe-element-offset.ts",
|
||||
"./regression/new-session-panel-corner.spec.ts",
|
||||
"./regression/session-timeline-context-resize.spec.ts",
|
||||
"./regression/workspaces.spec.ts",
|
||||
"./utils/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,15 +32,110 @@ export interface MockServerConfig {
|
||||
todos?: (sessionID: string) => unknown[]
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
forms?: unknown[] | (() => unknown[])
|
||||
fileList?: (path: string) => unknown | Promise<unknown>
|
||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => 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) {
|
||||
const cursors = new Map<string, string>()
|
||||
let nextCursor = 0
|
||||
const streamPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
|
||||
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)
|
||||
if (!state.controller) {
|
||||
state.buffer.push(...frames)
|
||||
return
|
||||
}
|
||||
for (const item of frames) state.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: {} })),
|
||||
)
|
||||
for (const item of state.buffer.splice(0)) 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: streamPort, 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> = {
|
||||
"/path": {
|
||||
state: config.directory,
|
||||
@@ -175,6 +270,14 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (path === "/api/project") return json(route, [config.project])
|
||||
if (path === "/api/project/current")
|
||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||
if (/^\/api\/project\/[^/]+\/directories$/.test(path))
|
||||
return json(route, [
|
||||
{ directory: config.directory },
|
||||
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
|
||||
directory,
|
||||
strategy: "git_worktree",
|
||||
})),
|
||||
])
|
||||
if (path === "/api/location") return json(route, location(config))
|
||||
const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
|
||||
if (projectCopy && route.request().method() === "POST") {
|
||||
@@ -195,6 +298,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
location: location(config),
|
||||
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")
|
||||
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
|
||||
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
||||
@@ -243,7 +351,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const limit = Number(url.searchParams.get("limit") ?? 50)
|
||||
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
||||
const sessions = config.sessions
|
||||
.filter((session) => !directory || session.directory === directory)
|
||||
.filter((session) => {
|
||||
const location = session.location as { directory?: string } | undefined
|
||||
return !directory || location?.directory === directory || session.directory === directory
|
||||
})
|
||||
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
||||
.filter((session) => {
|
||||
const search = url.searchParams.get("search")?.toLowerCase()
|
||||
@@ -280,6 +391,19 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||
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\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
|
||||
return json(route, { data: [] })
|
||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
@@ -466,6 +590,7 @@ function currentPermission(value: unknown) {
|
||||
|
||||
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
|
||||
const time = session.time && typeof session.time === "object" ? session.time : {}
|
||||
const location = session.location && typeof session.location === "object" ? session.location : {}
|
||||
return {
|
||||
id: session.id,
|
||||
parentID: session.parentID,
|
||||
@@ -483,10 +608,19 @@ export function currentSession(session: { id: string } & Record<string, unknown>
|
||||
},
|
||||
title: session.title ?? session.id,
|
||||
location: {
|
||||
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
||||
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
||||
directory:
|
||||
"directory" in location && typeof location.directory === "string"
|
||||
? location.directory
|
||||
: typeof session.directory === "string"
|
||||
? session.directory
|
||||
: fallbackDirectory,
|
||||
...(typeof session.workspaceID === "string"
|
||||
? { workspaceID: session.workspaceID }
|
||||
: "workspaceID" in location && typeof location.workspaceID === "string"
|
||||
? { workspaceID: location.workspaceID }
|
||||
: {}),
|
||||
},
|
||||
subpath: session.path,
|
||||
subpath: session.subpath ?? session.path,
|
||||
revert: session.revert,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,23 +29,38 @@ export type SseEventOptions = {
|
||||
|
||||
export type SseTransport<T> = {
|
||||
server: string
|
||||
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
|
||||
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
waitForConnection(options?: {
|
||||
after?: number
|
||||
timeout?: number
|
||||
path?: SseConnectionRecord["path"]
|
||||
}): Promise<SseConnectionRecord>
|
||||
send(payload: T, options?: SseEventOptions, path?: SseConnectionRecord["path"]): Promise<SseDeliveryAcknowledgement>
|
||||
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
|
||||
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
|
||||
close(): Promise<void>
|
||||
disconnect(message?: string): Promise<void>
|
||||
error(message?: string): Promise<void>
|
||||
error(message?: string, path?: SseConnectionRecord["path"]): Promise<void>
|
||||
connections(): Promise<SseConnectionRecord[]>
|
||||
acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
|
||||
}
|
||||
|
||||
type BrowserCommand<T> =
|
||||
| { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] }
|
||||
| {
|
||||
type: "send"
|
||||
deliveries: { payload: T; options?: SseEventOptions }[]
|
||||
burst: boolean
|
||||
cuts?: number[]
|
||||
path?: SseConnectionRecord["path"]
|
||||
}
|
||||
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
|
||||
| { type: "end"; mode: "close" | "disconnect" | "error"; message?: string }
|
||||
| {
|
||||
type: "end"
|
||||
mode: "close" | "disconnect" | "error"
|
||||
message?: string
|
||||
path?: SseConnectionRecord["path"]
|
||||
}
|
||||
| { type: "connections" }
|
||||
| { type: "acknowledgements" }
|
||||
|
||||
@@ -73,7 +88,8 @@ export async function installSseTransport<T>(
|
||||
let nextConnectionID = 0
|
||||
let nextDeliveryID = 0
|
||||
|
||||
const current = () => connections.findLast((connection) => connection.endedAt === undefined)
|
||||
const current = (path?: SseConnectionRecord["path"]) =>
|
||||
connections.findLast((connection) => connection.endedAt === undefined && (!path || connection.path === path))
|
||||
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
|
||||
const boundaries = [...new Set(cuts ?? [])]
|
||||
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
|
||||
@@ -125,8 +141,8 @@ export async function installSseTransport<T>(
|
||||
acknowledgements.push(acknowledgement)
|
||||
return acknowledgement
|
||||
}
|
||||
const end = (mode: "close" | "disconnect" | "error", message?: string) => {
|
||||
const connection = current()
|
||||
const end = (mode: "close" | "disconnect" | "error", message?: string, path?: SseConnectionRecord["path"]) => {
|
||||
const connection = current(path)
|
||||
if (!connection) throw new Error("SSE transport has no active connection")
|
||||
connection.endedAt = performance.now()
|
||||
connection.endedBy = mode
|
||||
@@ -146,8 +162,8 @@ export async function installSseTransport<T>(
|
||||
if (input.type === "connections")
|
||||
return connections.map(({ controller: _controller, ...connection }) => connection)
|
||||
if (input.type === "acknowledgements") return acknowledgements
|
||||
if (input.type === "end") return end(input.mode, input.message)
|
||||
const connection = current()
|
||||
if (input.type === "end") return end(input.mode, input.message, input.path)
|
||||
const connection = current(input.type === "send" ? input.path : undefined)
|
||||
if (!connection) throw new Error("SSE transport has no active connection")
|
||||
if (input.type === "raw") {
|
||||
marker(input.marker)
|
||||
@@ -235,12 +251,15 @@ export async function installSseTransport<T>(
|
||||
server,
|
||||
async waitForConnection(input = {}) {
|
||||
const connection = await page.waitForFunction(
|
||||
(after) => {
|
||||
({ after, path }) => {
|
||||
const transport = (window as BrowserTransport).__testSseTransport
|
||||
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
|
||||
return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined)
|
||||
return connections?.findLast(
|
||||
(connection) =>
|
||||
connection.id > after && connection.endedAt === undefined && (!path || connection.path === path),
|
||||
)
|
||||
},
|
||||
input.after ?? 0,
|
||||
{ after: input.after ?? 0, path: input.path },
|
||||
{ timeout: input.timeout },
|
||||
)
|
||||
let result: SseConnectionRecord | undefined
|
||||
@@ -252,8 +271,8 @@ export async function installSseTransport<T>(
|
||||
if (!result) throw new Error("SSE transport connection disappeared while waiting")
|
||||
return result
|
||||
},
|
||||
send(payload, eventOptions) {
|
||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
|
||||
send(payload, eventOptions, path) {
|
||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, path })
|
||||
},
|
||||
burst(payloads, eventOptions = []) {
|
||||
return command({
|
||||
@@ -291,8 +310,8 @@ export async function installSseTransport<T>(
|
||||
disconnect(message) {
|
||||
return command({ type: "end", mode: "disconnect", message })
|
||||
},
|
||||
error(message) {
|
||||
return command({ type: "end", mode: "error", message })
|
||||
error(message, path) {
|
||||
return command({ type: "end", mode: "error", message, path })
|
||||
},
|
||||
connections() {
|
||||
return command({ type: "connections" })
|
||||
|
||||
+31
-26
@@ -1,6 +1,7 @@
|
||||
import "@/index.css"
|
||||
import * as Sentry from "@sentry/solid"
|
||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||
import type { UiI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { File } from "@opencode-ai/session-ui/file"
|
||||
@@ -53,8 +54,7 @@ import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
import { TargetSessionRouteContent } from "@/pages/session"
|
||||
import { SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
||||
import { Home } from "@/pages/home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
@@ -84,21 +84,24 @@ function TargetServerRoute(props: ParentProps) {
|
||||
})
|
||||
|
||||
return (
|
||||
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
||||
<ServerSDKProvider server={conn()}>
|
||||
<ServerSyncProvider server={conn()}>{props.children}</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const TargetSessionRoute = () => (
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
)
|
||||
|
||||
function TargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
return (
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
</SessionRouteErrorBoundary>
|
||||
)
|
||||
}
|
||||
// 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.
|
||||
function SelectedServerProviders(props: ParentProps) {
|
||||
@@ -130,16 +133,14 @@ function DraftRoute() {
|
||||
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
const global = useGlobal()
|
||||
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 (
|
||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<ModelsProvider directory={directory}>
|
||||
<SDKProvider directory={directory}>
|
||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||
<ServerSDKProvider server={conn()}>
|
||||
<ServerSyncProvider server={conn()}>
|
||||
<ModelsProvider directory={props.draft.directory}>
|
||||
<SDKProvider directory={props.draft.directory}>
|
||||
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}>
|
||||
<DraftProviders>
|
||||
<NewSession />
|
||||
</DraftProviders>
|
||||
@@ -156,7 +157,13 @@ function UiI18nBridge(props: ParentProps) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<I18nProvider
|
||||
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
|
||||
value={{
|
||||
locale: language.intl,
|
||||
layoutLocale: language.layoutLocale,
|
||||
t: language.t as UiI18n["t"],
|
||||
plural: language.plural,
|
||||
pluralForm: language.pluralForm,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</I18nProvider>
|
||||
@@ -237,7 +244,7 @@ function DesktopCommands() {
|
||||
}
|
||||
|
||||
type ServerScopedShellProps = ParentProps<{
|
||||
directory?: () => string | undefined
|
||||
directory?: string
|
||||
serverScoped?: JSX.Element
|
||||
}>
|
||||
|
||||
@@ -450,12 +457,10 @@ export function AppInterface(props: {
|
||||
// route changes. Draft and session routes override only their server-bound data
|
||||
// providers beneath it.
|
||||
const ServerShell = (shellProps: ParentProps) => (
|
||||
<QueryProvider>
|
||||
<SharedProviders>
|
||||
{props.children}
|
||||
{shellProps.children}
|
||||
</SharedProviders>
|
||||
</QueryProvider>
|
||||
<SharedProviders>
|
||||
{props.children}
|
||||
{shellProps.children}
|
||||
</SharedProviders>
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||
import { batch, createEffect, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
@@ -571,7 +571,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
value={language.t(`debugBar.direction.${language.direction()}`)}
|
||||
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
|
||||
/>
|
||||
{platform.setForceFocus && (
|
||||
<Show when={platform.setForceFocus}>
|
||||
<ToggleCell
|
||||
active={state.focus}
|
||||
inline={props.inline}
|
||||
@@ -580,7 +580,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
|
||||
onClick={() => void toggleFocus()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</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 { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { ExternalLink } from "@/components/external-link"
|
||||
@@ -20,6 +20,7 @@ import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { useIntegrations } from "@/hooks/use-integrations"
|
||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller"
|
||||
@@ -40,7 +41,7 @@ export function useProviderConnectController(options: { onBack?: () => void } =
|
||||
}
|
||||
|
||||
export const DialogConnectProvider: Component<{
|
||||
directory?: Accessor<string | undefined>
|
||||
directory?: string
|
||||
controller?: ReturnType<typeof useProviderConnectController>
|
||||
}> = (props) => {
|
||||
const fallback = useProviderConnectController()
|
||||
@@ -136,15 +137,11 @@ export const DialogConnectProvider: Component<{
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderPicker(props: {
|
||||
directory?: Accessor<string | undefined>
|
||||
onSelect: (provider: string) => void
|
||||
onPrepare?: () => void
|
||||
}) {
|
||||
function ProviderPicker(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
|
||||
const settings = useSettings()
|
||||
const integrations = useIntegrations(() => props.directory)
|
||||
if (settings.general.newLayoutDesigns())
|
||||
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
return <ProviderPickerV2 integrations={integrations.list} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
||||
const language = useLanguage()
|
||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||
@@ -166,7 +163,7 @@ function ProviderPicker(props: {
|
||||
key={(x) => x?.id}
|
||||
items={() => {
|
||||
language.locale()
|
||||
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
|
||||
return [{ id: CUSTOM_ID, name: customLabel() }, ...integrations.list()]
|
||||
}}
|
||||
filterKeys={["id", "name"]}
|
||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
||||
@@ -212,11 +209,10 @@ function ProviderPicker(props: {
|
||||
}
|
||||
|
||||
function ProviderPickerV2(props: {
|
||||
directory?: Accessor<string | undefined>
|
||||
integrations: ReturnType<typeof useIntegrations>["list"]
|
||||
onSelect: (provider: string) => void
|
||||
onPrepare?: () => void
|
||||
}) {
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
filter: "",
|
||||
@@ -228,7 +224,7 @@ function ProviderPickerV2(props: {
|
||||
const all = createMemo(() => {
|
||||
language.locale()
|
||||
const query = store.filter.trim().toLowerCase()
|
||||
const values = [custom(), ...providers.all().values()]
|
||||
const values = [custom(), ...props.integrations()]
|
||||
if (!query) return values
|
||||
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
|
||||
})
|
||||
@@ -364,7 +360,7 @@ function ProviderPickerV2(props: {
|
||||
|
||||
function ProviderConnection(props: {
|
||||
provider: string
|
||||
directory?: Accessor<string | undefined>
|
||||
directory?: string
|
||||
onBack: () => void
|
||||
setBack: (handler: () => void) => void
|
||||
}) {
|
||||
@@ -374,12 +370,9 @@ function ProviderConnection(props: {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const newLayout = settings.general.newLayoutDesigns
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
const directory = () => props.directory?.() ?? decode64(params.dir)
|
||||
const providers = useProviders(() => props.directory)
|
||||
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({
|
||||
provider: () => props.provider,
|
||||
directory,
|
||||
@@ -393,6 +386,18 @@ function ProviderConnection(props: {
|
||||
})
|
||||
},
|
||||
})
|
||||
const provider = createMemo(
|
||||
() =>
|
||||
providers.all().get(props.provider) ??
|
||||
serverSync().data.provider.all.get(props.provider) ?? {
|
||||
id: props.provider,
|
||||
name: controller.integration()?.name ?? props.provider,
|
||||
source: "custom" as const,
|
||||
env: [],
|
||||
options: {},
|
||||
models: {},
|
||||
},
|
||||
)
|
||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||
if (!value) return ""
|
||||
if (value.type === "key") return language.t("provider.connect.method.apiKey")
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
.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,156 +1,223 @@
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Field } from "@opencode-ai/ui/v2/field-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { Icon as IconV2 } 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 { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { For, Show } from "solid-js"
|
||||
import { For, Show, createSignal, startTransition } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
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 }) {
|
||||
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 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 (
|
||||
<Dialog fit>
|
||||
<form onSubmit={model.submit} class="contents">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DividerV2 />
|
||||
<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>
|
||||
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
||||
<TextInputV2
|
||||
autofocus
|
||||
appearance="large"
|
||||
class="!w-full"
|
||||
value={model.store.name}
|
||||
placeholder={model.folderName()}
|
||||
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<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.icon")}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.icon.alt")}
|
||||
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"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
||||
}}
|
||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||
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={{
|
||||
"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}
|
||||
<Dialog size="x-large" variant="settings" class="project-settings-v2-dialog">
|
||||
<TabsV2
|
||||
orientation="vertical"
|
||||
variant="settings"
|
||||
value={tab()}
|
||||
onChange={(value) => void startTransition(() => setTab(value))}
|
||||
class="project-settings-v2"
|
||||
>
|
||||
<TabsV2.List>
|
||||
<div class="project-settings-v2-nav">
|
||||
<TabsV2.Trigger value="general">
|
||||
<ProjectAvatar
|
||||
fallback={projectName()}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
class="!size-4 shrink-0"
|
||||
/>
|
||||
<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>
|
||||
<span class="truncate">{projectName()}</span>
|
||||
</TabsV2.Trigger>
|
||||
<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>
|
||||
|
||||
<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")}
|
||||
<TabsV2.Content value="general" class="project-settings-v2-panel">
|
||||
<form onSubmit={model.submit} class="project-settings-v2-form">
|
||||
<div class="project-settings-v2-scroll">
|
||||
<div class="project-settings-page-header">
|
||||
<h2>{language.t("dialog.project.edit.title")}</h2>
|
||||
<span>{language.t("project.settings.general.description")}</span>
|
||||
</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"
|
||||
|
||||
<Field>
|
||||
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
||||
<TextInputV2
|
||||
autofocus
|
||||
appearance="large"
|
||||
class="!w-full"
|
||||
value={model.store.name}
|
||||
placeholder={model.folderName()}
|
||||
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<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.icon")}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.icon.alt")}
|
||||
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"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
||||
}}
|
||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||
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={{
|
||||
"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,
|
||||
)
|
||||
"opacity-100": model.store.iconHover,
|
||||
"opacity-0": !model.store.iconHover,
|
||||
}}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
variant={getProjectAvatarVariant(color)}
|
||||
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<IconV2 name={model.store.iconOverride ? "close" : "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>
|
||||
</Show>
|
||||
|
||||
<Field>
|
||||
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
||||
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
||||
<TextareaV2
|
||||
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
||||
rows={3}
|
||||
value={model.store.startup}
|
||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||
spellcheck={false}
|
||||
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
</DialogBody>
|
||||
<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>
|
||||
</form>
|
||||
<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>
|
||||
<Footer />
|
||||
</form>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="scripts" class="project-settings-v2-panel">
|
||||
<form onSubmit={model.submit} class="project-settings-v2-form">
|
||||
<div class="project-settings-v2-scroll">
|
||||
<div class="project-settings-page-header">
|
||||
<h2>{language.t("project.settings.scripts")}</h2>
|
||||
<span>{language.t("project.settings.scripts.description")}</span>
|
||||
</div>
|
||||
<Field>
|
||||
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
||||
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
||||
<TextareaV2
|
||||
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
||||
rows={5}
|
||||
value={model.store.startup}
|
||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||
spellcheck={false}
|
||||
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Footer />
|
||||
</form>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="extensions" class="project-settings-v2-panel">
|
||||
<ProjectSettingsExtensions />
|
||||
</TabsV2.Content>
|
||||
</TabsV2>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
void dialog.show(() => <DialogConnectProvider directory={directory} />)
|
||||
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
|
||||
}
|
||||
const providerRank = (id: string) => popularProviders.indexOf(id)
|
||||
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 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 providerVisible = (providerID: string) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { createSignal, Index, Show } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -83,61 +83,71 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
||||
{/* Bottom section - buttons and indicators (fixed position) */}
|
||||
<div class="flex flex-col gap-12">
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
{isLast() ? (
|
||||
<Show
|
||||
when={isLast()}
|
||||
fallback={
|
||||
<Button variant="secondary" size="large" onClick={handleNext}>
|
||||
{language.t("dialog.releaseNotes.action.next")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button variant="primary" size="large" onClick={handleClose}>
|
||||
{language.t("dialog.releaseNotes.action.getStarted")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="secondary" size="large" onClick={handleNext}>
|
||||
{language.t("dialog.releaseNotes.action.next")}
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Button variant="ghost" size="small" onClick={handleDisable}>
|
||||
{language.t("dialog.releaseNotes.action.hideFuture")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{paged() && (
|
||||
<Show when={paged()}>
|
||||
<div class="flex items-center gap-1.5 -my-2.5">
|
||||
{props.highlights.map((_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
<Index each={props.highlights}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
|
||||
classList={{
|
||||
"bg-icon-strong-base": i === index(),
|
||||
"bg-icon-weak-base": i !== index(),
|
||||
"w-8": i === index(),
|
||||
"w-3": i !== index(),
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
onClick={() => setIndex(i)}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Media content (edge to edge) */}
|
||||
{feature()?.media && (
|
||||
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
|
||||
{feature()!.media!.type === "image" ? (
|
||||
<img
|
||||
src={feature()!.media!.src}
|
||||
alt={feature()!.media!.alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<video src={feature()!.media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Show when={feature()?.media}>
|
||||
{(media) => (
|
||||
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
|
||||
<Show
|
||||
when={media().type === "image"}
|
||||
fallback={
|
||||
<video src={media().src} autoplay loop muted playsinline class="w-full h-full object-cover" />
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={media().src}
|
||||
alt={media().alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useTheme } from "@opencode-ai/ui/theme"
|
||||
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useIntegrations } from "@/hooks/use-integrations"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
@@ -22,7 +22,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme()
|
||||
const directory = () => decode64(local.slug())
|
||||
const providers = useProviders(directory)
|
||||
const integrations = useIntegrations(directory)
|
||||
const language = useLanguage()
|
||||
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
|
||||
const currentKey = createMemo(() => {
|
||||
@@ -37,7 +37,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
const controller = x.useProviderConnectController()
|
||||
controller.select(provider)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -127,7 +127,8 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
</div>
|
||||
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
|
||||
<For
|
||||
each={[...providers.popular()]
|
||||
each={integrations
|
||||
.list()
|
||||
.filter((provider) => featuredProviders.includes(provider.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) => {
|
||||
const controller = x.useProviderConnectController()
|
||||
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 = () => {
|
||||
close("provider")
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
|
||||
})
|
||||
}
|
||||
const language = useLanguage()
|
||||
@@ -240,7 +240,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||
trigger={props.trigger}
|
||||
models={controller.models}
|
||||
groups={controller.groups}
|
||||
current={controller.current}
|
||||
current={controller.current()}
|
||||
select={controller.select}
|
||||
onManage={() => {
|
||||
void import("./dialog-manage-models").then((module) => {
|
||||
@@ -295,7 +295,7 @@ function ModelSelectorPopoverV2View(props: {
|
||||
trigger: ModelSelectorTrigger
|
||||
models: (search: string) => ModelItem[]
|
||||
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||
current: () => string | undefined
|
||||
current: string | undefined
|
||||
select: (item: ModelItem) => void
|
||||
onManage: () => void
|
||||
onClose: () => void
|
||||
@@ -310,7 +310,7 @@ function ModelSelectorPopoverV2View(props: {
|
||||
const groups = createMemo(() => props.groups(models()))
|
||||
const keys = () => [...models().map(modelKey), manageKey]
|
||||
const initialActive = () => {
|
||||
const selected = props.current()
|
||||
const selected = props.current
|
||||
const options = keys()
|
||||
if (selected && options.includes(selected)) return selected
|
||||
return options[0] ?? ""
|
||||
@@ -453,7 +453,7 @@ function ModelSelectorPopoverV2View(props: {
|
||||
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||
</MenuV2.GroupLabel>
|
||||
<MenuV2.RadioGroup value={props.current()}>
|
||||
<MenuV2.RadioGroup value={props.current}>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<TooltipV2
|
||||
@@ -473,7 +473,7 @@ function ModelSelectorPopoverV2View(props: {
|
||||
<MenuV2.RadioItem
|
||||
value={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"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||
onMouseEnter={() => {
|
||||
@@ -529,7 +529,7 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
|
||||
|
||||
const provider = () => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export type PromptInputV2ComposerProps = {
|
||||
class?: string
|
||||
controller: PromptInputV2ComposerController
|
||||
borderUnderlay?: boolean
|
||||
accentSubmit?: boolean
|
||||
}
|
||||
|
||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
||||
@@ -53,6 +54,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
||||
<div class="flex flex-col gap-3">
|
||||
<PromptInputV2
|
||||
controller={props.controller}
|
||||
accentSubmit={props.accentSubmit}
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
class={props.class}
|
||||
variantControlVisible={!props.controller.model.loading}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Prompt, PromptStore } from "@/context/prompt"
|
||||
import type { ModelSelection } from "@/context/local"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
const createdClients: string[] = []
|
||||
const createdSessions: string[] = []
|
||||
const sessionCreateInputs: Array<{
|
||||
type SessionCreateInput = {
|
||||
agent?: string
|
||||
model?: { id: string; providerID: string; variant?: string }
|
||||
location?: { directory: string }
|
||||
}> = []
|
||||
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||
}
|
||||
const optimistic: Array<{
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
@@ -22,11 +21,9 @@ const optimistic: Array<{
|
||||
variant?: string
|
||||
}
|
||||
}> = []
|
||||
const optimisticSeeded: boolean[] = []
|
||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||
const syncedDirectories: string[] = []
|
||||
const sentShellDirectories: string[] = []
|
||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||
const sentPrompts: string[] = []
|
||||
const promptInputs: unknown[] = []
|
||||
@@ -37,15 +34,29 @@ const switchedModels: Array<{
|
||||
model: { id: string; providerID: string; variant?: string }
|
||||
}> = []
|
||||
const sessionRequestOrder: string[] = []
|
||||
const commands: Array<{ name: string }> = []
|
||||
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
||||
const syncedServers: string[] = []
|
||||
const optimisticServers: string[] = []
|
||||
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
||||
let serverSessionSyncs = 0
|
||||
|
||||
let params: { id?: string } = {}
|
||||
let search: { draftId?: string } = {}
|
||||
let selected = "/repo/worktree-a"
|
||||
let variant: string | undefined
|
||||
let permissionServer = "server-a"
|
||||
let createSessionGate: Promise<void> | undefined
|
||||
let createWorktreeGate: Promise<void> | undefined
|
||||
let worktreeFailure: Error | undefined
|
||||
let worktreeHung = false
|
||||
let worktreeCreates = 0
|
||||
let activeSDK = "server-a"
|
||||
let activeServerSync = "server-a"
|
||||
let activeDirectorySync = "server-a"
|
||||
let commands: Array<{ name: string }> = []
|
||||
let worktreeDirectory = "/repo/new-0"
|
||||
let worktreeID = 0
|
||||
const draftServers: Record<string, string> = {}
|
||||
const sessionDirectories: Record<string, string> = {}
|
||||
|
||||
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||
@@ -73,21 +84,25 @@ const prompt = {
|
||||
replaceComments: () => undefined,
|
||||
items: () => [],
|
||||
},
|
||||
capture: () => prompt,
|
||||
capture: (scope?: unknown, target?: unknown) => {
|
||||
promptCaptures.push({ scope, target })
|
||||
return prompt
|
||||
},
|
||||
}
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
const clientFor = (directory: string) => {
|
||||
createdClients.push(directory)
|
||||
return {
|
||||
api: {
|
||||
session: {
|
||||
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
||||
create: async (input: SessionCreateInput) => {
|
||||
await createSessionGate
|
||||
const location = input.location?.directory ?? directory
|
||||
createdSessions.push(location)
|
||||
sessionCreateInputs.push(input)
|
||||
const id = `session-${createdSessions.length}`
|
||||
sessionDirectories[id] = location
|
||||
return {
|
||||
id: `session-${createdSessions.length}`,
|
||||
id,
|
||||
projectID: "project",
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
@@ -100,7 +115,7 @@ const clientFor = (directory: string) => {
|
||||
},
|
||||
prompt: async (input: unknown) => {
|
||||
sessionRequestOrder.push("prompt")
|
||||
sentPrompts.push(directory)
|
||||
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
|
||||
promptInputs.push(input)
|
||||
return { data: undefined }
|
||||
},
|
||||
@@ -120,6 +135,19 @@ const clientFor = (directory: string) => {
|
||||
},
|
||||
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||
sentShell.push(input)
|
||||
sentShellDirectories.push(sessionDirectories[input.sessionID] ?? directory)
|
||||
},
|
||||
},
|
||||
projectCopy: {
|
||||
create: async (_input: unknown, options?: { signal?: AbortSignal }) => {
|
||||
worktreeCreates++
|
||||
if (worktreeHung)
|
||||
return new Promise<never>((_, reject) => {
|
||||
options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true })
|
||||
})
|
||||
await createWorktreeGate
|
||||
if (worktreeFailure) throw worktreeFailure
|
||||
return { directory: worktreeDirectory }
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -127,9 +155,6 @@ const clientFor = (directory: string) => {
|
||||
command: async () => ({ data: undefined }),
|
||||
abort: async () => ({ data: undefined }),
|
||||
},
|
||||
worktree: {
|
||||
create: async () => ({ data: { directory: `${directory}/new` } }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +170,7 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@opencode-ai/ui/toast", () => ({
|
||||
Toast: { Region: () => null },
|
||||
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
|
||||
showToast: () => 0,
|
||||
}))
|
||||
|
||||
@@ -162,20 +188,13 @@ beforeAll(async () => {
|
||||
current: () => ({ name: "agent" }),
|
||||
},
|
||||
session: {
|
||||
promote(directory: string, sessionID: string) {
|
||||
promoted.push({ directory, sessionID })
|
||||
},
|
||||
promote: () => undefined,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/permission", () => {
|
||||
const state = (server: string) => ({
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
enabledAutoAccept.push({ server, sessionID, directory })
|
||||
},
|
||||
})
|
||||
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
|
||||
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
|
||||
})
|
||||
|
||||
mock.module("@/context/server", () => ({
|
||||
@@ -184,7 +203,10 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@/context/tabs", () => ({
|
||||
useTabs: () => ({
|
||||
draft: () => ({ server: "project-server" }),
|
||||
draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }),
|
||||
updateDraft: (draftID: string, draft: { worktree?: string }) => {
|
||||
updatedDrafts.push({ draftID, ...draft })
|
||||
},
|
||||
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
||||
promotedDrafts.push({ draftID, ...session })
|
||||
},
|
||||
@@ -205,68 +227,70 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@/context/sdk", () => ({
|
||||
useSDK: () => {
|
||||
const sdk = {
|
||||
scope: "local",
|
||||
directory: "/repo/main",
|
||||
return () => ({
|
||||
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
|
||||
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
|
||||
api: rootClient.api,
|
||||
url: "http://localhost:4096",
|
||||
}
|
||||
return () => sdk
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/sync", () => ({
|
||||
useSync: () => () => ({
|
||||
data: { command: commands },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||
}) => {
|
||||
optimistic.push(value)
|
||||
optimisticSeeded.push(
|
||||
!!value.directory &&
|
||||
!!value.sessionID &&
|
||||
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
|
||||
)
|
||||
useSync: () => () => {
|
||||
const server = activeDirectorySync
|
||||
return {
|
||||
data: { command: commands, project: "project" },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||
}) => {
|
||||
optimisticServers.push(server)
|
||||
optimistic.push(value)
|
||||
},
|
||||
remove: () => undefined,
|
||||
},
|
||||
remove: () => undefined,
|
||||
},
|
||||
},
|
||||
set: () => undefined,
|
||||
}),
|
||||
set: () => undefined,
|
||||
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/server-sync", () => ({
|
||||
useServerSync: () => () => ({
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
set: () => undefined,
|
||||
sync: async () => {
|
||||
serverSessionSyncs++
|
||||
},
|
||||
},
|
||||
child: (directory: string) => {
|
||||
syncedDirectories.push(directory)
|
||||
storedSessions[directory] ??= []
|
||||
return [
|
||||
{ session: storedSessions[directory] },
|
||||
(...args: unknown[]) => {
|
||||
if (args[0] !== "session") return
|
||||
const next = args[1]
|
||||
if (typeof next === "function") {
|
||||
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
||||
return
|
||||
}
|
||||
if (Array.isArray(next)) {
|
||||
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
||||
}
|
||||
useServerSync: () => () => {
|
||||
const server = activeServerSync
|
||||
return {
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
set: () => undefined,
|
||||
sync: async () => {
|
||||
serverSessionSyncs++
|
||||
},
|
||||
]
|
||||
},
|
||||
}),
|
||||
},
|
||||
child: (directory: string) => {
|
||||
syncedServers.push(server)
|
||||
storedSessions[directory] ??= []
|
||||
return [
|
||||
{ session: storedSessions[directory] },
|
||||
(...args: unknown[]) => {
|
||||
if (args[0] !== "session") return
|
||||
const next = args[1]
|
||||
if (typeof next === "function") {
|
||||
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
||||
return
|
||||
}
|
||||
if (Array.isArray(next)) {
|
||||
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/platform", () => ({
|
||||
@@ -286,205 +310,159 @@ beforeAll(async () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
createdClients.length = 0
|
||||
createdSessions.length = 0
|
||||
sessionCreateInputs.length = 0
|
||||
enabledAutoAccept.length = 0
|
||||
optimistic.length = 0
|
||||
optimisticSeeded.length = 0
|
||||
promoted.length = 0
|
||||
promotedDrafts.length = 0
|
||||
updatedDrafts.length = 0
|
||||
sentCommands.length = 0
|
||||
sentPrompts.length = 0
|
||||
promptInputs.length = 0
|
||||
sentCommands.length = 0
|
||||
switchedAgents.length = 0
|
||||
switchedModels.length = 0
|
||||
sessionRequestOrder.length = 0
|
||||
commands.length = 0
|
||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
syncedServers.length = 0
|
||||
optimisticServers.length = 0
|
||||
promptCaptures.length = 0
|
||||
params = {}
|
||||
search = {}
|
||||
sentShell.length = 0
|
||||
syncedDirectories.length = 0
|
||||
sentShellDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
variant = undefined
|
||||
permissionServer = "server-a"
|
||||
activeSDK = "server-a"
|
||||
activeServerSync = "server-a"
|
||||
activeDirectorySync = "server-a"
|
||||
commands = []
|
||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
worktreeDirectory = `/repo/new-${++worktreeID}`
|
||||
createSessionGate = undefined
|
||||
serverSessionSyncs = 0
|
||||
createWorktreeGate = undefined
|
||||
worktreeFailure = undefined
|
||||
worktreeHung = false
|
||||
worktreeCreates = 0
|
||||
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
||||
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
const makeSubmit = (overrides: Partial<Parameters<typeof createPromptSubmit>[0]> = {}) =>
|
||||
createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe("prompt submit worktree selection", () => {
|
||||
test("reads the latest worktree accessor value per submit", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
test("admits only one concurrent new-workspace submission", async () => {
|
||||
selected = "create"
|
||||
let release = () => {}
|
||||
createWorktreeGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const submit = makeSubmit()
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
const first = submit.handleSubmit(event)
|
||||
const duplicate = submit.handleSubmit(event)
|
||||
expect(worktreeCreates).toBe(1)
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
selected = "/repo/worktree-b"
|
||||
await submit.handleSubmit(event)
|
||||
release()
|
||||
await Promise.all([first, duplicate])
|
||||
expect(createdSessions).toEqual([worktreeDirectory])
|
||||
await settle()
|
||||
|
||||
expect(createdClients).toEqual([])
|
||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sessionCreateInputs).toEqual([
|
||||
{
|
||||
agent: "agent",
|
||||
model: { id: "model", providerID: "provider", variant: undefined },
|
||||
location: { directory: "/repo/worktree-a" },
|
||||
},
|
||||
{
|
||||
agent: "agent",
|
||||
model: { id: "model", providerID: "provider", variant: undefined },
|
||||
location: { directory: "/repo/worktree-b" },
|
||||
},
|
||||
])
|
||||
expect(sentShell).toEqual([
|
||||
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||
])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||
expect(serverSessionSyncs).toBe(0)
|
||||
expect(promoted).toEqual([
|
||||
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
||||
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
||||
])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||
expect(worktreeCreates).toBe(1)
|
||||
expect(createdSessions).toHaveLength(1)
|
||||
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||
})
|
||||
|
||||
test("applies auto-accept to newly created sessions", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => true,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
test("aborts a hung new-workspace request and allows retry", async () => {
|
||||
selected = "create"
|
||||
worktreeHung = true
|
||||
let resets = 0
|
||||
const submit = makeSubmit({
|
||||
onNewSessionWorktreeReset: () => resets++,
|
||||
worktreeRequestTimeoutMs: 1,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
expect(worktreeCreates).toBe(1)
|
||||
expect(createdSessions).toEqual([])
|
||||
expect(selected).toBe("create")
|
||||
expect(promptValue).toEqual([{ type: "text", content: "ls", start: 0, end: 2 }])
|
||||
expect(resets).toBe(0)
|
||||
|
||||
worktreeHung = false
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(worktreeCreates).toBe(2)
|
||||
expect(createdSessions).toEqual([worktreeDirectory])
|
||||
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||
expect(resets).toBe(1)
|
||||
})
|
||||
|
||||
test("keeps auto-accept bound to the submission server", async () => {
|
||||
test("keeps async submission effects bound to the initiating context", async () => {
|
||||
search = { draftId: "draft-1" }
|
||||
draftServers["draft-1"] = "project-server-a"
|
||||
draftServers["draft-2"] = "project-server-b"
|
||||
let release = () => {}
|
||||
createSessionGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => true,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
let submitted = 0
|
||||
const submit = makeSubmit({
|
||||
onSubmit: () => submitted++,
|
||||
})
|
||||
|
||||
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
permissionServer = "server-b"
|
||||
const result = submit.handleSubmit(event)
|
||||
activeSDK = "server-b"
|
||||
activeServerSync = "server-b"
|
||||
activeDirectorySync = "server-b"
|
||||
search.draftId = "draft-2"
|
||||
release()
|
||||
await result
|
||||
await settle()
|
||||
|
||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
})
|
||||
|
||||
test("promotes drafts using the selected project's server", async () => {
|
||||
search = { draftId: "draft-1" }
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
|
||||
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
|
||||
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
|
||||
expect(optimisticServers).toEqual(["server-a"])
|
||||
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
|
||||
expect(WorkspaceOperation.get("server-b" as ServerScope, "session-1")).toBeUndefined()
|
||||
expect(submitted).toBe(0)
|
||||
})
|
||||
|
||||
test("switches the selected agent and model before prompting", async () => {
|
||||
params = { id: "session-1" }
|
||||
variant = "high"
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
const submit = makeSubmit({
|
||||
info: () => ({
|
||||
id: "session-1",
|
||||
agent: "old-agent",
|
||||
model: { id: "old-model", providerID: "old-provider" },
|
||||
}),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
await Bun.sleep(0)
|
||||
|
||||
@@ -519,24 +497,12 @@ describe("prompt submit worktree selection", () => {
|
||||
commands.push({ name: "review" })
|
||||
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
const submit = makeSubmit({
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(sentCommands).toEqual([
|
||||
{
|
||||
@@ -552,66 +518,20 @@ describe("prompt submit worktree selection", () => {
|
||||
expect(serverSessionSyncs).toBe(0)
|
||||
})
|
||||
|
||||
test("uses an injected model selection", async () => {
|
||||
params = { id: "session-1" }
|
||||
const model = {
|
||||
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
|
||||
variant: { current: () => "draft-variant" },
|
||||
} as unknown as ModelSelection
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
model,
|
||||
test("sends an initial shell after synchronous workspace creation", async () => {
|
||||
selected = "create"
|
||||
const submit = makeSubmit({
|
||||
mode: () => "shell",
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(optimistic[0]).toMatchObject({
|
||||
message: {
|
||||
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("seeds new sessions before optimistic prompts are added", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
|
||||
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
||||
expect(optimisticSeeded).toEqual([true])
|
||||
expect(sentShellDirectories).toEqual([worktreeDirectory])
|
||||
expect(sentShell[0]).toMatchObject({
|
||||
sessionID: "session-1",
|
||||
command: "ls",
|
||||
})
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,10 +17,12 @@ import { useSync, type DirectorySync } from "@/context/sync"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { WORKSPACE_PREPARATION_TIMEOUT_MS, workspaceRequestWithTimeout } from "@/utils/workspace-request"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { blobDataUrl } from "@/utils/draft-store"
|
||||
@@ -28,9 +30,13 @@ import { blobDataUrl } from "@/utils/draft-store"
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
cleanup: VoidFunction
|
||||
scope: ServerScope
|
||||
sessionID: string
|
||||
serverSync: ServerSync
|
||||
}
|
||||
|
||||
const pending = new Map<string, PendingPrompt>()
|
||||
const submitting = new Set<string>()
|
||||
|
||||
export type FollowupDraft = {
|
||||
sessionID: string
|
||||
@@ -44,6 +50,7 @@ export type FollowupDraft = {
|
||||
|
||||
type FollowupSendInput = {
|
||||
api: DirectorySDK["api"]["session"]
|
||||
scope: ServerScope
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||
@@ -58,6 +65,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ?
|
||||
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
|
||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
const operation = WorkspaceOperation.get(input.scope, input.draft.sessionID)
|
||||
if (operation?.status === "pending" && operation.messageID !== input.messageID) return false
|
||||
const text = draftText(input.draft.prompt)
|
||||
const images = draftImages(input.draft.prompt)
|
||||
const setBusy = () => {
|
||||
@@ -248,6 +257,7 @@ type PromptSubmitInput = {
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
model?: ModelSelection
|
||||
worktreeRequestTimeoutMs?: number
|
||||
}
|
||||
|
||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
@@ -263,7 +273,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||
const pendingKey = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID)
|
||||
let pendingSubmission: { key: string; scope: ServerScope; sessionID: string } | undefined
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
||||
@@ -276,18 +287,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
const abort = async () => {
|
||||
const sessionID = params.id
|
||||
const routeSessionID = params.id
|
||||
const owned =
|
||||
pendingSubmission && (!routeSessionID || routeSessionID === pendingSubmission.sessionID)
|
||||
? pending.get(pendingSubmission.key)
|
||||
: undefined
|
||||
const sessionID = routeSessionID ?? owned?.sessionID
|
||||
if (!sessionID) return Promise.resolve()
|
||||
|
||||
serverSync().session.set("todo", sessionID, [])
|
||||
;(owned?.serverSync ?? serverSync()).session.set("todo", sessionID, [])
|
||||
|
||||
input.onAbort?.()
|
||||
|
||||
const key = pendingKey(sessionID)
|
||||
const queued = pending.get(key)
|
||||
const key = owned ? pendingSubmission!.key : pendingKey(sdk().scope, sessionID)
|
||||
const queued = owned ?? pending.get(key)
|
||||
if (queued) {
|
||||
queued.abort.abort()
|
||||
queued.cleanup()
|
||||
WorkspaceOperation.fail(queued.scope, queued.sessionID)
|
||||
pending.delete(key)
|
||||
return Promise.resolve()
|
||||
}
|
||||
@@ -319,9 +335,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const seed = (dir: string, info: SessionInfo) => {
|
||||
serverSync().session.remember(info)
|
||||
const [, setStore] = serverSync().child(dir)
|
||||
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
|
||||
target.session.remember(info)
|
||||
const [, setStore] = target.child(dir)
|
||||
setStore("session", (list: SessionInfo[]) => {
|
||||
const result = Binary.search(list, info.id, (item) => item.id)
|
||||
const next = [...list]
|
||||
@@ -353,6 +369,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (input.working()) void abort()
|
||||
return
|
||||
}
|
||||
if (params.id && WorkspaceOperation.get(sdk().scope, params.id)?.status === "pending") return
|
||||
|
||||
const modelSelection = input.model ?? local.model
|
||||
const currentModel = modelSelection.current()
|
||||
@@ -366,284 +383,369 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return
|
||||
}
|
||||
|
||||
input.addToHistory(currentPrompt, mode)
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
const projectDirectory = sdk().directory
|
||||
const submissionSDK = sdk()
|
||||
const submissionSync = sync()
|
||||
const submissionServerSync = serverSync()
|
||||
const submissionScope = submissionSDK.scope
|
||||
const projectDirectory = submissionSDK.directory
|
||||
const projectRoot = submissionSync.project?.worktree ?? projectDirectory
|
||||
const sessionID = params.id
|
||||
const isNewSession = !sessionID
|
||||
const currentSession = input.info()
|
||||
const draftID = search.draftId
|
||||
const draftServer = draftID ? tabs.draft(draftID).server : undefined
|
||||
const capturePrompt = prompt.capture
|
||||
const localSession = local.session
|
||||
const handoff = layout.handoff
|
||||
const resetWorktree = input.onNewSessionWorktreeReset
|
||||
const onSubmit = input.onSubmit
|
||||
const permissionState = permission.currentServerState()
|
||||
const isNewSession = !params.id
|
||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
const submissionKey = ScopedKey.from(
|
||||
submissionScope,
|
||||
draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`,
|
||||
)
|
||||
if (submitting.has(submissionKey)) return
|
||||
submitting.add(submissionKey)
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await sdk()
|
||||
.api.projectCopy.create({
|
||||
projectID: sync().data.project,
|
||||
strategy: "git_worktree",
|
||||
directory: getDirectory(projectDirectory),
|
||||
location: { directory: projectDirectory },
|
||||
try {
|
||||
input.addToHistory(currentPrompt, mode)
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await workspaceRequestWithTimeout(
|
||||
(signal) =>
|
||||
submissionSDK.api.projectCopy.create(
|
||||
{
|
||||
projectID: submissionSync.data.project,
|
||||
strategy: "git_worktree",
|
||||
directory: getDirectory(projectDirectory),
|
||||
location: { directory: projectDirectory },
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
input.worktreeRequestTimeoutMs ?? WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!createdWorktree) return
|
||||
WorktreeState.ready(submissionScope, createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||
sessionDirectory = worktreeSelection
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
submissionServerSync.child(sessionDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
let session = currentSession
|
||||
if (!session && isNewSession) {
|
||||
const created = await submissionSDK.api.session
|
||||
.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
if (!createdWorktree) return
|
||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||
sessionDirectory = worktreeSelection
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
serverSync().child(sessionDirectory)
|
||||
}
|
||||
|
||||
input.onNewSessionWorktreeReset?.()
|
||||
}
|
||||
|
||||
let session = input.info()
|
||||
if (!session && isNewSession) {
|
||||
const created = await sdk()
|
||||
.api.session.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
if (created) {
|
||||
seed(submissionServerSync, sessionDirectory, created)
|
||||
session = created
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
if (draftID) tabs.updateDraft(draftID, { worktree: undefined })
|
||||
if (!draftID) resetWorktree?.()
|
||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||
localSession.promote(sessionDirectory, session.id, {
|
||||
agent: currentAgent.name,
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
variant: variant ?? null,
|
||||
})
|
||||
handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(
|
||||
capturePrompt(
|
||||
{ dir: base64Encode(sessionDirectory), id: session.id },
|
||||
{ server: draftServer, scope: submissionScope },
|
||||
),
|
||||
)
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
if (created) {
|
||||
seed(sessionDirectory, created)
|
||||
session = created
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id, {
|
||||
agent: currentAgent.name,
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
variant: variant ?? null,
|
||||
})
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
const draftID = search.draftId
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const model = {
|
||||
modelID: currentModel.id,
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const draft: FollowupDraft = {
|
||||
sessionID: session.id,
|
||||
sessionDirectory,
|
||||
prompt: currentPrompt,
|
||||
context,
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
}
|
||||
const model = {
|
||||
modelID: currentModel.id,
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const draft: FollowupDraft = {
|
||||
sessionID: session.id,
|
||||
sessionDirectory,
|
||||
prompt: currentPrompt,
|
||||
context,
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
}
|
||||
|
||||
const clearInput = () => {
|
||||
submission.clear()
|
||||
input.setMode("normal")
|
||||
input.setPopover(null)
|
||||
}
|
||||
const clearInput = () => {
|
||||
submission.clear()
|
||||
input.setMode("normal")
|
||||
input.setPopover(null)
|
||||
}
|
||||
|
||||
const restoreInput = () => {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||
if (!submission.current(prompt.capture())) return true
|
||||
input.setMode(mode)
|
||||
input.setPopover(null)
|
||||
requestAnimationFrame(() => {
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||
input.queueScroll()
|
||||
})
|
||||
return true
|
||||
}
|
||||
const restoreInput = () => {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||
if (!submission.current(prompt.capture())) return true
|
||||
input.setMode(mode)
|
||||
input.setPopover(null)
|
||||
requestAnimationFrame(() => {
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||
input.queueScroll()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||
input.onQueue?.(draft)
|
||||
clearContext(submission.target())
|
||||
clearInput()
|
||||
return
|
||||
}
|
||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||
input.onQueue?.(draft)
|
||||
clearContext(submission.target())
|
||||
clearInput()
|
||||
return
|
||||
}
|
||||
|
||||
input.onSubmit?.()
|
||||
const startWorkspaceOperation = (messageID: string) => {
|
||||
if (!isNewSession) return
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create" && sessionDirectory !== projectRoot) {
|
||||
WorkspaceOperation.start(submissionScope, session.id, "move", sessionDirectory, messageID)
|
||||
WorkspaceOperation.complete(submissionScope, session.id)
|
||||
}
|
||||
if (worktreeSelection !== "create") return
|
||||
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
|
||||
WorkspaceOperation.start(submissionScope, session.id, "create", sessionDirectory, messageID)
|
||||
if (worktree?.status === "ready") WorkspaceOperation.complete(submissionScope, session.id)
|
||||
if (worktree?.status === "failed") WorkspaceOperation.fail(submissionScope, session.id)
|
||||
}
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
const eventID = Event.ID.create()
|
||||
sdk()
|
||||
.api.session.shell({
|
||||
const waitForWorktree = async (cleanup: VoidFunction) => {
|
||||
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
|
||||
if (!worktree) return true
|
||||
if (worktree.status === "ready") {
|
||||
WorkspaceOperation.complete(submissionScope, session.id)
|
||||
return true
|
||||
}
|
||||
if (worktree.status === "failed") {
|
||||
WorkspaceOperation.fail(submissionScope, session.id)
|
||||
throw new Error(worktree.message)
|
||||
}
|
||||
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
submissionSync.set("session_status", session.id, { type: "busy" })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const key = pendingKey(submissionScope, session.id)
|
||||
pendingSubmission = { key, scope: submissionScope, sessionID: session.id }
|
||||
pending.set(key, {
|
||||
abort: controller,
|
||||
cleanup,
|
||||
scope: submissionScope,
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
serverSync: submissionServerSync,
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
|
||||
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
if (controller.signal.aborted) {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
return
|
||||
}
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
|
||||
const timeoutMs = 5 * 60 * 1000
|
||||
const timer = { id: undefined as number | undefined }
|
||||
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
timer.id = window.setTimeout(() => {
|
||||
resolve({
|
||||
status: "failed",
|
||||
message: language.t("workspace.error.stillPreparing"),
|
||||
})
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
const result = await Promise.race([
|
||||
WorktreeState.wait(submissionScope, sessionDirectory),
|
||||
abortWait,
|
||||
timeout,
|
||||
]).finally(() => {
|
||||
pending.delete(key)
|
||||
if (pendingSubmission?.key === key) pendingSubmission = undefined
|
||||
if (timer.id === undefined) return
|
||||
clearTimeout(timer.id)
|
||||
})
|
||||
if (controller.signal.aborted) return false
|
||||
if (result.status === "failed") {
|
||||
WorkspaceOperation.fail(submissionScope, session.id)
|
||||
throw new Error(result.message)
|
||||
}
|
||||
WorkspaceOperation.complete(submissionScope, session.id)
|
||||
return true
|
||||
}
|
||||
|
||||
if (!draftID || search.draftId === draftID) onSubmit?.()
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
const eventID = Event.ID.create()
|
||||
startWorkspaceOperation(eventID)
|
||||
void waitForWorktree(() => {
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
const commandName = cmdName.slice(1)
|
||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
const messageID = Identifier.ascending("message")
|
||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||
sdk()
|
||||
.api.session.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
name: attachment.filename,
|
||||
})),
|
||||
),
|
||||
.then((ready) => {
|
||||
if (!ready) return
|
||||
return submissionSDK.api.session.shell({
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
const messageID = Identifier.ascending("message")
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
sync().session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||
clearInput()
|
||||
|
||||
const waitForWorktree = async () => {
|
||||
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
|
||||
if (!worktree || worktree.status !== "pending") return true
|
||||
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync().set("session_status", session.id, { type: "busy" })
|
||||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
const commandName = cmdName.slice(1)
|
||||
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
const messageID = Identifier.ascending("message")
|
||||
startWorkspaceOperation(messageID)
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
|
||||
void waitForWorktree(() => {
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||
restoreInput()
|
||||
})
|
||||
.then(async (ready) => {
|
||||
if (!ready) return
|
||||
return submissionSDK.api.session.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
name: attachment.filename,
|
||||
})),
|
||||
),
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
const messageID = Identifier.ascending("message")
|
||||
startWorkspaceOperation(messageID)
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
submissionSync.session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||
clearInput()
|
||||
|
||||
const cleanup = () => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync().set("session_status", session.id, { type: "idle" })
|
||||
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
}
|
||||
|
||||
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
||||
|
||||
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
if (controller.signal.aborted) {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
return
|
||||
void sendFollowupDraft({
|
||||
api: submissionSDK.api.session,
|
||||
scope: submissionScope,
|
||||
sync: submissionSync,
|
||||
serverSync: submissionServerSync,
|
||||
session: () => session,
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
before: () => waitForWorktree(cleanup),
|
||||
}).catch((err) => {
|
||||
pending.delete(pendingKey(submissionScope, session.id))
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
})
|
||||
|
||||
const timeoutMs = 5 * 60 * 1000
|
||||
const timer = { id: undefined as number | undefined }
|
||||
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
timer.id = window.setTimeout(() => {
|
||||
resolve({
|
||||
status: "failed",
|
||||
message: language.t("workspace.error.stillPreparing"),
|
||||
})
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
const result = await Promise.race([
|
||||
WorktreeState.wait(sdk().scope, sessionDirectory),
|
||||
abortWait,
|
||||
timeout,
|
||||
]).finally(() => {
|
||||
if (timer.id === undefined) return
|
||||
clearTimeout(timer.id)
|
||||
})
|
||||
pending.delete(pendingKey(session.id))
|
||||
if (controller.signal.aborted) return false
|
||||
if (result.status === "failed") throw new Error(result.message)
|
||||
return true
|
||||
} finally {
|
||||
submitting.delete(submissionKey)
|
||||
}
|
||||
|
||||
void sendFollowupDraft({
|
||||
api: sdk().api.session,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
session: () => input.info() ?? session,
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
before: waitForWorktree,
|
||||
}).catch((err) => {
|
||||
pending.delete(pendingKey(session.id))
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync().set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -11,25 +10,42 @@ export function PromptWorkspaceSelector(props: {
|
||||
projectRoot: string
|
||||
workspaces: string[]
|
||||
branch?: string
|
||||
onboarding?: boolean
|
||||
onChange: (value: string) => void
|
||||
onDone: () => void
|
||||
onViewAll: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
let pending: string | undefined
|
||||
const [search, setSearch] = createSignal("")
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
let focusSearch = false
|
||||
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
|
||||
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
||||
const workspaces = createMemo(() => {
|
||||
const query = search().trim().toLowerCase()
|
||||
if (!query) return props.workspaces
|
||||
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
|
||||
})
|
||||
const icon = () => {
|
||||
if (selected() === "main") return "monitor"
|
||||
if (selected() === "create") return "workspace-new"
|
||||
return "workspace"
|
||||
return "workspace-isolated"
|
||||
}
|
||||
const select = (value: string) => {
|
||||
pending = value
|
||||
pending = { type: "select", value }
|
||||
}
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) return
|
||||
const value = pending
|
||||
if (open) {
|
||||
setSearch("")
|
||||
return
|
||||
}
|
||||
const action = pending
|
||||
pending = undefined
|
||||
if (value) props.onChange(value)
|
||||
if (action?.type === "select") props.onChange(action.value)
|
||||
if (action?.type === "viewAll") {
|
||||
props.onViewAll()
|
||||
return
|
||||
}
|
||||
props.onDone()
|
||||
}
|
||||
const label = () => {
|
||||
@@ -41,87 +57,220 @@ export function PromptWorkspaceSelector(props: {
|
||||
return (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
|
||||
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class="w-[180px]">
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item onSelect={() => select("main")}>
|
||||
<IconV2 name="monitor" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
|
||||
<Show when={selected() === "main"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => select("create")}>
|
||||
<IconV2 name="workspace-new" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
||||
<Show when={selected() === "create"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
<Show when={props.workspaces.length > 0}>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<MenuV2.SubTrigger>
|
||||
<IconV2 name="workspace" />
|
||||
{language.t("session.new.workspace.existing")}
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="max-w-[200px]">
|
||||
<For each={props.workspaces}>
|
||||
{(workspace) => (
|
||||
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||
<IconV2 name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
<Show when={selected() === workspace}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.SubContent>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2.Sub>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
value={
|
||||
props.onboarding ? (
|
||||
<div class="flex flex-col gap-1 text-left">
|
||||
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
|
||||
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||
<span>{language.t("workspace.onboarding.title")}</span>
|
||||
</div>
|
||||
<span class="font-[440] text-v2-text-text-muted">
|
||||
{language.t("workspace.onboarding.description")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
language.t("session.new.workspace.trigger.tooltip")
|
||||
)
|
||||
}
|
||||
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||
class="min-w-0"
|
||||
>
|
||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<MenuV2.Trigger
|
||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
||||
>
|
||||
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<Show when={props.onboarding}>
|
||||
<span
|
||||
data-slot="workspace-onboarding-dot"
|
||||
aria-hidden="true"
|
||||
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
|
||||
/>
|
||||
</Show>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<PromptGitStatus branch={props.branch} />
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class="w-[200px]">
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item onSelect={() => select("main")}>
|
||||
<Icon name="monitor" />
|
||||
<TooltipV2
|
||||
placement="right"
|
||||
openDelay={800}
|
||||
value={
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span>{language.t("session.new.workspace.local")}</span>
|
||||
<span class="font-[440] text-v2-text-text-muted">
|
||||
{language.t("session.new.workspace.local.tooltip")}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
|
||||
</TooltipV2>
|
||||
<Show when={selected() === "main"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => select("create")}>
|
||||
<Icon name="workspace-new" />
|
||||
<TooltipV2
|
||||
placement="right"
|
||||
openDelay={800}
|
||||
value={
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span>{language.t("workspace.new")}</span>
|
||||
<span class="font-[440] text-v2-text-text-muted">
|
||||
{language.t("session.new.workspace.new.tooltip")}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
|
||||
</TooltipV2>
|
||||
<Show when={selected() === "create"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
<Show
|
||||
when={props.workspaces.length > 0}
|
||||
fallback={
|
||||
<>
|
||||
<MenuV2.Separator class="h-[0.5px]" />
|
||||
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</MenuV2.Item>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuV2.Separator class="h-[0.5px]" />
|
||||
<MenuV2.Sub
|
||||
gutter={0}
|
||||
overlap
|
||||
overflowPadding={8}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
focusSearch = false
|
||||
return
|
||||
}
|
||||
if (!focusSearch || props.workspaces.length < 10) return
|
||||
focusSearch = false
|
||||
requestAnimationFrame(() => searchInput?.focus())
|
||||
}}
|
||||
>
|
||||
<MenuV2.SubTrigger
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "ArrowRight" ||
|
||||
event.key === "ArrowLeft" ||
|
||||
event.key === "Enter" ||
|
||||
event.key === " "
|
||||
)
|
||||
focusSearch = true
|
||||
}}
|
||||
>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</span>
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||
<Show when={props.workspaces.length >= 10}>
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={(element) => {
|
||||
searchInput = element
|
||||
}}
|
||||
value={search()}
|
||||
placeholder={language.t("session.new.workspace.search.placeholder")}
|
||||
aria-label={language.t("session.new.workspace.search.placeholder")}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Escape" ||
|
||||
event.key === "ArrowDown" ||
|
||||
event.key === "ArrowUp" ||
|
||||
event.key === "Enter"
|
||||
)
|
||||
return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
<Show when={selected() === workspace}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
)}
|
||||
</For>
|
||||
<MenuV2.Separator class="h-[0.5px]" />
|
||||
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.SubContent>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2.Sub>
|
||||
</Show>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</TooltipV2>
|
||||
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ml-1" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
|
||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
|
||||
const language = useLanguage()
|
||||
const label = () => {
|
||||
if (props.noGit) return language.t("session.new.git.none")
|
||||
if (!props.branch) return undefined
|
||||
if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch })
|
||||
return props.branch
|
||||
}
|
||||
|
||||
const icon = () => {
|
||||
if (props.noGit) return "monitor"
|
||||
if (props.from) return "branch-out"
|
||||
return "branch"
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={label()}>
|
||||
{(value) => (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
value={value()}
|
||||
class="min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{value()}</span>
|
||||
</div>
|
||||
</TooltipV2>
|
||||
</>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
value={value()}
|
||||
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
|
||||
<Icon
|
||||
name={icon()}
|
||||
size="small"
|
||||
class="shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{value()}</span>
|
||||
</div>
|
||||
</TooltipV2>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -238,6 +238,7 @@ export function createProviderConnectionController(options: {
|
||||
|
||||
return {
|
||||
loading: () => integration.loading,
|
||||
integration: () => integration.latest,
|
||||
methods,
|
||||
currentMethod,
|
||||
methodIndex: () => store.methodIndex,
|
||||
|
||||
@@ -96,13 +96,16 @@ export function ServerRow(props: ServerRowProps) {
|
||||
{(conn) => (
|
||||
<div class="flex flex-row gap-3">
|
||||
<span>
|
||||
{conn().http.username ? (
|
||||
<Show
|
||||
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-weaker">{language.t("server.row.noUsername")}</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
{conn().http.password && <span class="text-text-weak">••••••••</span>}
|
||||
<Show when={conn().http.password}>
|
||||
<span class="text-text-weak">••••••••</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import type { Project } from "@/types"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { Worktree } from "@/utils/worktree"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { workspaceDirectories } from "@/utils/workspace"
|
||||
import {
|
||||
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
workspaceRequestWithTimeout,
|
||||
} from "@/utils/workspace-request"
|
||||
|
||||
export function SessionWorkspaceMenu(props: {
|
||||
eligible?: boolean
|
||||
sessionID: string
|
||||
project: Project
|
||||
directory: string
|
||||
messageID?: string
|
||||
placement?: ComponentProps<typeof MenuV2>["placement"]
|
||||
gutter?: number
|
||||
class?: string
|
||||
contentClass?: string
|
||||
children: JSX.Element
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const openWorkspaces = useSettingsDialog("workspaces")
|
||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||
const operationPending = () => WorkspaceOperation.get(serverSDK().scope, props.sessionID)?.status === "pending"
|
||||
const blocked = () =>
|
||||
props.eligible === false || operationPending() || serverSync().session.data.session_working(props.sessionID)
|
||||
const workspaces = () =>
|
||||
workspaceDirectories(props.project).filter((workspace) => pathKey(workspace) !== pathKey(props.directory))
|
||||
|
||||
const fail = (scope: ServerScope, sessionID: string, message: string) => {
|
||||
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
|
||||
WorkspaceOperation.fail(scope, sessionID)
|
||||
showToast({ variant: "error", title: language.t("workspace.move.failed"), description: message })
|
||||
}
|
||||
const move = async (selection: "create" | string) => {
|
||||
if (store.selected || blocked()) return
|
||||
const sdk = serverSDK()
|
||||
const sync = serverSync()
|
||||
const scope = sdk.scope
|
||||
const sessionID = props.sessionID
|
||||
const messageID = props.messageID
|
||||
const source = props.directory
|
||||
setStore("selected", selection)
|
||||
|
||||
try {
|
||||
const destination =
|
||||
selection === "create"
|
||||
? await createWorkspace(
|
||||
props.project,
|
||||
source,
|
||||
sessionID,
|
||||
messageID,
|
||||
sdk,
|
||||
(message) => fail(scope, sessionID, message),
|
||||
{
|
||||
createFailed: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
},
|
||||
)
|
||||
: selection
|
||||
if (!destination) return
|
||||
|
||||
WorkspaceOperation.start(scope, sessionID, selection === "create" ? "create" : "move", destination, messageID)
|
||||
if (sync.session.data.session_working(sessionID)) throw new Error(language.t("workspace.move.failed"))
|
||||
await workspaceRequestWithTimeout(
|
||||
(signal) => sdk.api.session.move({ sessionID, directory: destination }, { signal }),
|
||||
language.t("workspace.move.failed"),
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
const session = await workspaceRequestWithTimeout(
|
||||
(signal) => sync.session.resolve(sessionID, { force: true, signal }),
|
||||
language.t("workspace.move.failed"),
|
||||
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
|
||||
)
|
||||
if (!session || pathKey(session.location.directory) !== pathKey(destination))
|
||||
throw new Error(language.t("workspace.move.failed"))
|
||||
WorkspaceOperation.complete(scope, sessionID, destination)
|
||||
sync.reindexSession(sessionID, source)
|
||||
} catch (error) {
|
||||
fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed"))
|
||||
} finally {
|
||||
setStore("selected", undefined)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuV2
|
||||
placement={props.placement ?? "bottom-end"}
|
||||
gutter={props.gutter ?? 4}
|
||||
modal={false}
|
||||
onOpenChange={props.onOpenChange}
|
||||
>
|
||||
<MenuV2.Trigger class={props.class} disabled={blocked()}>
|
||||
{props.children}
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class={`w-[200px] ${props.contentClass ?? ""}`}>
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("workspace.move.menu.title")}</MenuV2.GroupLabel>
|
||||
<Show when={pathKey(props.directory) !== pathKey(props.project.worktree)}>
|
||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
|
||||
<Icon name="monitor" />
|
||||
{language.t("session.new.workspace.local")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
|
||||
<Icon name="workspace-new" />
|
||||
{language.t("workspace.new")}
|
||||
</MenuV2.Item>
|
||||
<Show when={workspaces().length > 0}>
|
||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<MenuV2.SubTrigger>
|
||||
<Icon name="workspace-isolated" />
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
</MenuV2.Item>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.SubContent>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2.Sub>
|
||||
</Show>
|
||||
</MenuV2.Group>
|
||||
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
|
||||
<MenuV2.Item onSelect={() => openWorkspaces()}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
)
|
||||
}
|
||||
|
||||
async function createWorkspace(
|
||||
project: Project,
|
||||
source: string,
|
||||
sessionID: string,
|
||||
messageID: string | undefined,
|
||||
serverSDK: ReturnType<ReturnType<typeof useServerSDK>>,
|
||||
fail: (message: string) => void,
|
||||
messages: { createFailed: string },
|
||||
) {
|
||||
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", project.worktree, messageID)
|
||||
const created = await workspaceRequestWithTimeout(
|
||||
(signal) =>
|
||||
serverSDK.api.projectCopy.create(
|
||||
{
|
||||
projectID: project.id,
|
||||
strategy: "git_worktree",
|
||||
directory: getDirectory(source),
|
||||
location: { directory: source },
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
messages.createFailed,
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
.catch((error) => {
|
||||
fail(error instanceof Error ? error.message : messages.createFailed)
|
||||
return undefined
|
||||
})
|
||||
if (!created?.directory) return
|
||||
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", created.directory, messageID)
|
||||
Worktree.ready(serverSDK.scope, created.directory)
|
||||
return created.directory
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { FileVisual } from "./session-sortable-tab"
|
||||
|
||||
export function SortableTabV2(props: {
|
||||
tab: string
|
||||
index: () => number
|
||||
index: number
|
||||
temporary?: boolean
|
||||
onTabClose: (tab: string) => void
|
||||
onTabDoubleClick?: (tab: string) => void
|
||||
@@ -26,7 +26,7 @@ export function SortableTabV2(props: {
|
||||
return props.tab
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
return props.index
|
||||
},
|
||||
})
|
||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||
|
||||
@@ -14,7 +14,7 @@ import { focusTerminalById } from "@/pages/session/helpers"
|
||||
|
||||
export function SortableTerminalTabV2(props: {
|
||||
terminal: LocalPTY
|
||||
index: () => number
|
||||
index: number
|
||||
newLayout: boolean
|
||||
onClose?: () => void
|
||||
}): JSX.Element {
|
||||
@@ -25,7 +25,7 @@ export function SortableTerminalTabV2(props: {
|
||||
return props.terminal.id
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
return props.index
|
||||
},
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -424,9 +425,9 @@ function SettingsKeybindsV2() {
|
||||
filtered={controller.catalog.filtered}
|
||||
title={controller.catalog.title}
|
||||
keybind={controller.catalog.keybind}
|
||||
active={controller.capture.active}
|
||||
active={controller.capture.active()}
|
||||
onCapture={controller.capture.toggle}
|
||||
hasOverrides={controller.settings.hasOverrides}
|
||||
hasOverrides={controller.settings.hasOverrides()}
|
||||
onReset={controller.settings.reset}
|
||||
/>
|
||||
)
|
||||
@@ -437,9 +438,9 @@ function SettingsKeybindsV2View(props: {
|
||||
filtered: (query: string) => Map<KeybindGroup, string[]>
|
||||
title: (id: string) => string
|
||||
keybind: (id: string) => string
|
||||
active: () => string | null
|
||||
active: string | null
|
||||
onCapture: (id: string) => void
|
||||
hasOverrides: () => boolean
|
||||
hasOverrides: boolean
|
||||
onReset: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
@@ -451,8 +452,11 @@ function SettingsKeybindsV2View(props: {
|
||||
<>
|
||||
<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.shortcuts.title")}</h2>
|
||||
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||
<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")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
@@ -498,12 +502,12 @@ function SettingsKeybindsV2View(props: {
|
||||
data-keybind-id={id}
|
||||
classList={{
|
||||
"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)}
|
||||
>
|
||||
<Show
|
||||
when={props.active() === id}
|
||||
when={props.active === id}
|
||||
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
||||
>
|
||||
{language.t("settings.shortcuts.pressKeys")}
|
||||
@@ -674,8 +678,6 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||
</Show>
|
||||
)
|
||||
|
||||
const List = props.v2 ? SettingsListV2 : SettingsList
|
||||
|
||||
const groups = (
|
||||
<div
|
||||
classList={{
|
||||
@@ -700,7 +702,7 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||
>
|
||||
{language.t(groupKey[group])}
|
||||
</h3>
|
||||
<List>
|
||||
<Dynamic component={props.v2 ? SettingsListV2 : SettingsList}>
|
||||
<For each={filtered().get(group) ?? []}>
|
||||
{(id) => (
|
||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
@@ -735,7 +737,7 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</List>
|
||||
</Dynamic>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { QueryClientProvider } from "@tanstack/solid-query"
|
||||
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) {
|
||||
const global = useGlobal()
|
||||
return (
|
||||
<Show when={global.settings.server.selected()} keyed fallback={props.children}>
|
||||
{(server) => <SettingsServerDataScope server={server}>{props.children}</SettingsServerDataScope>}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any }>) {
|
||||
const global = useGlobal()
|
||||
const serverCtx = () => global.ensureServerCtx(props.server)
|
||||
return (
|
||||
<QueryClientProvider client={serverCtx().queryClient}>
|
||||
<ServerSDKProvider server={props.server}>
|
||||
<ServerSyncProvider server={props.server}>
|
||||
<ModelsProvider>{props.children}</ModelsProvider>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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,15 +5,23 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { SettingsGeneralV2 } from "./general"
|
||||
import { SettingsAppearanceV2 } from "./appearance"
|
||||
import { SettingsKeybinds } from "../settings-keybinds"
|
||||
import { SettingsNotificationsV2 } from "./notifications"
|
||||
import { SettingsProvidersV2 } from "./providers"
|
||||
import { SettingsModelsV2 } from "./models"
|
||||
import "./settings-v2.css"
|
||||
import { SettingsServersV2 } from "./servers"
|
||||
import { SettingsWorkspacesV2 } from "./workspaces"
|
||||
import { SettingsProjectsV2 } from "./projects"
|
||||
import { SettingsExtensionsV2 } from "./extensions"
|
||||
import { SettingsServerScope } from "../settings-server-picker"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import "./settings-v2.css"
|
||||
|
||||
export const DialogSettings: Component<{
|
||||
sessionID?: string
|
||||
@@ -25,8 +33,13 @@ export const DialogSettings: Component<{
|
||||
const layout = useLayout()
|
||||
const tabs = useTabs()
|
||||
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 directory = createMemo(() => {
|
||||
const server = global.settings.server.selected()
|
||||
if (!server || serverSync() !== global.ensureServerCtx(server).sync) return
|
||||
const route = layout.route()
|
||||
if (route.type === "dir-new-sesssion") return route.dir
|
||||
if (route.type === "draft") {
|
||||
@@ -52,62 +65,96 @@ export const DialogSettings: Component<{
|
||||
>
|
||||
<TabsV2.List>
|
||||
<div class="flex flex-col justify-between h-full w-full">
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<TabsV2.Trigger value="general">
|
||||
<Icon name="sliders" />
|
||||
{language.t("settings.tab.general")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="shortcuts">
|
||||
<Icon name="keyboard" />
|
||||
{language.t("settings.tab.shortcuts")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 w-full">
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<TabsV2.Trigger value="general">
|
||||
<Icon name="sliders" />
|
||||
{language.t("settings.tab.preferences")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="appearance">
|
||||
<Icon name="appearance" />
|
||||
{language.t("settings.general.section.appearance")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="notifications">
|
||||
<Icon name="notifications" />
|
||||
{language.t("settings.tab.notifications")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="shortcuts">
|
||||
<Icon name="keyboard" />
|
||||
{language.t("settings.tab.shortcuts")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<TabsV2.Trigger value="servers">
|
||||
<Icon name="server" />
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="providers">
|
||||
<Icon name="providers" />
|
||||
{language.t("settings.providers.title")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="models">
|
||||
<Icon name="models" />
|
||||
{language.t("settings.models.title")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<TabsV2.Trigger value="workspaces">
|
||||
<Icon name="workspace-isolated" />
|
||||
{language.t("settings.tab.workspaces")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="servers">
|
||||
<Icon name="server" />
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="projects">
|
||||
<Icon name="folder" />
|
||||
{language.t("settings.tab.projects")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<TabsV2.Trigger value="providers">
|
||||
<Icon name="providers" />
|
||||
{language.t("settings.providers.title")}
|
||||
</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 class="settings-v2-nav-footer">
|
||||
<span>{language.t("app.name.desktop")}</span>
|
||||
<span>v{platform.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsV2.List>
|
||||
|
||||
<TabsV2.Content value="general" class="settings-v2-panel">
|
||||
<SettingsGeneralV2 sessionID={props.sessionID} />
|
||||
</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">
|
||||
<SettingsKeybinds v2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="workspaces" class="settings-v2-panel">
|
||||
<SettingsWorkspacesV2 activeDirectory={directory()} />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="servers" class="settings-v2-panel">
|
||||
<SettingsServersV2 />
|
||||
</TabsV2.Content>
|
||||
<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 value="projects" class="settings-v2-panel">
|
||||
<SettingsProjectsV2 />
|
||||
</TabsV2.Content>
|
||||
<SettingsServerScope>
|
||||
<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>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useUpdaterAction } from "../updater-action"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
@@ -85,6 +85,34 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
|
||||
)
|
||||
}
|
||||
|
||||
const WorkspaceDestinationSetting: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const options = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [
|
||||
{ value: "last-used", label: language.t("settings.workspaces.default.lastUsed") },
|
||||
{ value: "local", label: language.t("settings.workspaces.default.local") },
|
||||
{ value: "new", label: language.t("settings.workspaces.default.new") },
|
||||
])
|
||||
|
||||
return (
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.workspaces.default.title")}
|
||||
description={language.t("settings.workspaces.default.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
options={options()}
|
||||
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
|
||||
value={(option) => option.value}
|
||||
label={(option) => option.label}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
}
|
||||
|
||||
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
const options = createMemo(() =>
|
||||
@@ -279,8 +307,6 @@ export const SettingsGeneralV2: Component<{
|
||||
const updater = useUpdaterAction()
|
||||
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
||||
const shell = createShellSettingsController()
|
||||
const appearance = createAppearanceSettingsController()
|
||||
const sounds = createSoundSettingsController()
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||
@@ -298,9 +324,11 @@ export const SettingsGeneralV2: Component<{
|
||||
|
||||
const GeneralSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.general")}</h3>
|
||||
<SettingsListV2>
|
||||
<LanguageSetting />
|
||||
|
||||
<WorkspaceDestinationSetting />
|
||||
<PermissionScopeSetting controller={permissionScope} />
|
||||
|
||||
<ShellSetting controller={shell} />
|
||||
@@ -363,18 +391,6 @@ export const SettingsGeneralV2: Component<{
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showFileTree.title")}
|
||||
description={language.t("settings.general.row.showFileTree.description")}
|
||||
>
|
||||
<div data-action="settings-show-file-tree">
|
||||
<Switch
|
||||
checked={settings.general.showFileTree()}
|
||||
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showSearch.title")}
|
||||
description={language.t("settings.general.row.showSearch.description")}
|
||||
@@ -510,18 +526,19 @@ export const SettingsGeneralV2: Component<{
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<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 class="settings-v2-tab-body">
|
||||
<GeneralSection />
|
||||
|
||||
<AppearanceSection controller={appearance} />
|
||||
|
||||
<NotificationsSection />
|
||||
|
||||
<SoundsSection controller={sounds} />
|
||||
|
||||
<Show when={desktop()}>
|
||||
<UpdatesSection />
|
||||
</Show>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useModels } from "@/context/models"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import "./settings-v2.css"
|
||||
@@ -53,7 +54,13 @@ export const SettingsModelsV2: Component = () => {
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<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">
|
||||
<TextInputV2
|
||||
type="search"
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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,12 +4,14 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||
import { SettingsServerScope } from "../settings-server-picker"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import "./settings-v2.css"
|
||||
|
||||
@@ -30,25 +32,35 @@ const PROVIDER_NOTES = [
|
||||
const PROVIDER_ICON_SIZE = 16
|
||||
|
||||
export const SettingsProvidersV2: Component<{
|
||||
directory: Accessor<string | undefined>
|
||||
directory: string | undefined
|
||||
onBack?: () => void
|
||||
}> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders(props.directory)
|
||||
const providers = useProviders(() => props.directory)
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
|
||||
const connect = (provider?: string) => {
|
||||
providerConnect.select(provider)
|
||||
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
|
||||
void dialog.show(() => (
|
||||
<SettingsServerScope>
|
||||
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
|
||||
</SettingsServerScope>
|
||||
))
|
||||
}
|
||||
|
||||
const connected = createMemo(() => {
|
||||
return providers
|
||||
.connected()
|
||||
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
|
||||
return providers.connected().filter(
|
||||
(provider) =>
|
||||
provider.id !== "opencode" ||
|
||||
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(() => {
|
||||
@@ -92,31 +104,8 @@ export const SettingsProvidersV2: Component<{
|
||||
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 location = props.directory() ? { directory: props.directory() } : undefined
|
||||
const location = props.directory ? { directory: props.directory } : undefined
|
||||
await serverSdk()
|
||||
.api.integration.get({ integrationID: providerID, location })
|
||||
.then(async (integration) => {
|
||||
@@ -141,7 +130,13 @@ export const SettingsProvidersV2: Component<{
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<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 class="settings-v2-tab-body settings-v2-providers">
|
||||
@@ -244,7 +239,11 @@ export const SettingsProvidersV2: Component<{
|
||||
variant="neutral"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||
dialog.show(() => (
|
||||
<SettingsServerScope>
|
||||
<DialogCustomProvider onBack={dialog.close} />
|
||||
</SettingsServerScope>
|
||||
))
|
||||
}}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
|
||||
@@ -53,7 +53,10 @@ export const SettingsServersV2: Component = () => {
|
||||
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
|
||||
>
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
|
||||
<div class="flex flex-col gap-1">
|
||||
<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} />
|
||||
</div>
|
||||
<Show when={showSearch()}>
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
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 {
|
||||
font-size: 15px;
|
||||
font-weight: 640;
|
||||
@@ -174,13 +182,13 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
||||
[data-slot="tabs-v2-list"] {
|
||||
> [data-slot="tabs-v2-list"] {
|
||||
width: 144px;
|
||||
min-width: 144px;
|
||||
padding-inline: 8px;
|
||||
@@ -684,6 +692,223 @@
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-tab-header.settings-v2-workspaces-header {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-header .settings-v2-tab-title {
|
||||
font-weight: 610;
|
||||
}
|
||||
|
||||
.settings-v2-tab-body.settings-v2-workspaces {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-count {
|
||||
font-size: 15px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-delete-all {
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--v2-background-bg-base);
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row:not(:last-child) {
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row-header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-main [data-component="tooltip-v2-trigger"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-path {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--v2-text-text-base);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-meta {
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-active,
|
||||
.settings-v2-workspaces-more {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-sessions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 4px;
|
||||
background-color: var(--v2-background-bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 16px;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session:not(:last-child) {
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session > span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-block: 48px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.settings-v2-workspaces-header {
|
||||
padding: 24px 20px 20px;
|
||||
}
|
||||
|
||||
.settings-v2-tab-body.settings-v2-workspaces {
|
||||
padding: 0 20px 24px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar,
|
||||
.settings-v2-workspaces-main {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-path {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-active {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 32px);
|
||||
@@ -727,3 +952,24 @@
|
||||
line-height: 1;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import type { Component } from "solid-js"
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import type { Project } from "@/types"
|
||||
import {
|
||||
containsDirectory,
|
||||
filterWorkspaceInventory,
|
||||
inspectWorkspaceDeletion,
|
||||
mergeWorkspaceSessionInventory,
|
||||
removeWorkspacesSequentially,
|
||||
sessionsForWorkspace,
|
||||
type WorkspaceDeleteInspection,
|
||||
workspaceInventory,
|
||||
} from "@/utils/workspace"
|
||||
import { listAllSessions } from "@/utils/session"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import "./settings-v2.css"
|
||||
|
||||
type Workspace = {
|
||||
directory: string
|
||||
project: Project
|
||||
}
|
||||
|
||||
export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const tabs = useTabs()
|
||||
const platform = usePlatform()
|
||||
const [store, setStore] = createStore({
|
||||
project: "all",
|
||||
transaction: undefined as "confirm" | "running" | undefined,
|
||||
})
|
||||
|
||||
const workspaces = createMemo(() => workspaceInventory(serverSync().data.project))
|
||||
const projects = createMemo(() => serverSync().data.project.filter((project) => project.sandboxes?.length))
|
||||
const projectName = (project: Project) => project.name || getFilename(project.worktree)
|
||||
const projectOptions = createMemo(() => [
|
||||
{ id: "all", label: language.t("settings.workspaces.filter.all") },
|
||||
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
|
||||
])
|
||||
const selectedProject = createMemo(() =>
|
||||
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
|
||||
)
|
||||
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
|
||||
const captureDeleteContext = () => {
|
||||
const sdk = serverSDK()
|
||||
return { sdk, sync: serverSync(), server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
|
||||
}
|
||||
const loadSessions = async (context = captureDeleteContext()) => {
|
||||
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
|
||||
return mergeWorkspaceSessionInventory(
|
||||
fetched,
|
||||
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session),
|
||||
)
|
||||
}
|
||||
const sessionQuery = useQuery(() => ({
|
||||
queryKey: [serverSDK().scope, null, "settings-workspace-sessions"] as const,
|
||||
queryFn: () => loadSessions(),
|
||||
refetchOnMount: "always",
|
||||
}))
|
||||
const workspaceSessions = (workspace: Workspace) => {
|
||||
if (!sessionQuery.isSuccess) return []
|
||||
return sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory)
|
||||
}
|
||||
const sessionCount = (workspace: Workspace) => {
|
||||
if (sessionQuery.isPending) return language.t("session.messages.loading")
|
||||
if (sessionQuery.isError) return language.t("common.requestFailed")
|
||||
const count = workspaceSessions(workspace).length
|
||||
return language.plural("settings.workspaces.sessions", count, {
|
||||
count,
|
||||
project: projectName(workspace.project),
|
||||
})
|
||||
}
|
||||
const lastActive = (workspace: Workspace) => {
|
||||
const updated = workspaceSessions(workspace)[0]?.time.updated
|
||||
if (!updated) return undefined
|
||||
return getRelativeTime(new Date(updated).toISOString(), language.t)
|
||||
}
|
||||
const sessionTime = (session: SessionInfo) => {
|
||||
if (!session.time.updated) return undefined
|
||||
return getRelativeTime(new Date(session.time.updated).toISOString(), language.t)
|
||||
}
|
||||
|
||||
const inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
|
||||
const [working, branch, sessions] = await Promise.all([
|
||||
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
|
||||
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
|
||||
loadSessions(context),
|
||||
])
|
||||
const result = inspectWorkspaceDeletion({
|
||||
workspace: workspace.directory,
|
||||
activeDirectory: context.activeDirectory,
|
||||
sessions,
|
||||
status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean",
|
||||
})
|
||||
return { result, sessions }
|
||||
}
|
||||
const inspectionMessage = (result: WorkspaceDeleteInspection) => {
|
||||
if (result === "active") return language.t("settings.workspaces.delete.blocked.active")
|
||||
if (result === "linked") return language.t("settings.workspaces.delete.blocked.linked")
|
||||
if (result === "dirty") return language.t("workspace.status.dirty")
|
||||
return language.t("workspace.status.clean")
|
||||
}
|
||||
const blocked = (result: WorkspaceDeleteInspection) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: inspectionMessage(result),
|
||||
})
|
||||
}
|
||||
|
||||
const remove = async (workspace: Workspace, allowDirty = false, context = captureDeleteContext()) => {
|
||||
const preflight = await inspect(workspace, context)
|
||||
if (preflight.result !== "safe" && (!allowDirty || preflight.result !== "dirty")) {
|
||||
blocked(preflight.result)
|
||||
return
|
||||
}
|
||||
const removed = await context.sdk.api.projectCopy
|
||||
.remove({
|
||||
projectID: workspace.project.id,
|
||||
location: { directory: workspace.project.worktree },
|
||||
directory: workspace.directory,
|
||||
force: allowDirty,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
return false
|
||||
})
|
||||
if (!removed) return
|
||||
tabs.store.forEach((tab) => {
|
||||
if (tab.type !== "draft" || tab.server !== context.server) return
|
||||
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
|
||||
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
|
||||
if (!directoryMatches && !worktreeMatches) return
|
||||
tabs.updateDraft(tab.draftID, {
|
||||
directory: directoryMatches ? workspace.project.worktree : tab.directory,
|
||||
worktree: undefined,
|
||||
})
|
||||
})
|
||||
clearWorkspaceTerminals(
|
||||
workspace.directory,
|
||||
preflight.sessions.map((session) => session.id),
|
||||
platform,
|
||||
context.sdk.scope,
|
||||
)
|
||||
context.sync.set(
|
||||
"project",
|
||||
produce((draft) => {
|
||||
const project = draft.find((item) => item.id === workspace.project.id)
|
||||
if (!project) return
|
||||
project.sandboxes = (project.sandboxes ?? []).filter(
|
||||
(directory) => pathKey(directory) !== pathKey(workspace.directory),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
let inspectionID = 0
|
||||
const releaseConfirmation = () => {
|
||||
if (store.transaction === "confirm") setStore("transaction", undefined)
|
||||
}
|
||||
const transact = async (task: () => Promise<void>) => {
|
||||
if (store.transaction !== "confirm") return
|
||||
setStore("transaction", "running")
|
||||
try {
|
||||
await task()
|
||||
} catch (error) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
} finally {
|
||||
setStore("transaction", undefined)
|
||||
}
|
||||
}
|
||||
const confirmDelete = (workspace: Workspace) => {
|
||||
if (store.transaction) return
|
||||
const context = captureDeleteContext()
|
||||
const current = ++inspectionID
|
||||
setStore("transaction", "confirm")
|
||||
void dialog.push(
|
||||
() => (
|
||||
<DialogDeleteWorkspace
|
||||
workspace={workspace}
|
||||
scope={context.sdk.scope}
|
||||
inspectionID={current}
|
||||
inspect={() => inspect(workspace, context)}
|
||||
inspectionMessage={inspectionMessage}
|
||||
onDelete={() => transact(() => remove(workspace, true, context))}
|
||||
/>
|
||||
),
|
||||
releaseConfirmation,
|
||||
)
|
||||
}
|
||||
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
|
||||
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context))
|
||||
}
|
||||
const confirmDeleteAll = () => {
|
||||
if (store.transaction) return
|
||||
const context = captureDeleteContext()
|
||||
const inventory = [...filtered()]
|
||||
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
|
||||
setStore("transaction", "confirm")
|
||||
void dialog.push(
|
||||
() => (
|
||||
<DialogDeleteAllWorkspaces
|
||||
count={inventory.length}
|
||||
project={project}
|
||||
onDelete={() => transact(() => removeAll(inventory, context))}
|
||||
/>
|
||||
),
|
||||
releaseConfirmation,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header settings-v2-workspaces-header">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body settings-v2-workspaces">
|
||||
<div class="settings-v2-workspaces-toolbar">
|
||||
<span class="settings-v2-workspaces-count">
|
||||
{language.plural("settings.workspaces.count", filtered().length)}
|
||||
</span>
|
||||
<div class="settings-v2-workspaces-toolbar-actions">
|
||||
<Show when={projects().length > 1}>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
options={projectOptions()}
|
||||
current={projectOptions().find((option) => option.id === selectedProject())}
|
||||
value={(option) => option.id}
|
||||
label={(option) => option.label}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && setStore("project", option.id)}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={filtered().length > 0}>
|
||||
<MenuV2 placement="bottom-end" gutter={4}>
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
disabled={!!store.transaction}
|
||||
icon={<Icon name="outline-dots" size="small" />}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Item onSelect={confirmDeleteAll}>
|
||||
<span class="settings-v2-workspaces-delete-all">
|
||||
{language.t("settings.workspaces.deleteAll")}
|
||||
</span>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-workspaces-inventory">
|
||||
<Show
|
||||
when={filtered().length > 0}
|
||||
fallback={<div class="settings-v2-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
|
||||
>
|
||||
<SettingsListV2>
|
||||
<For each={filtered()}>
|
||||
{(workspace) => {
|
||||
const linked = () => workspaceSessions(workspace)
|
||||
return (
|
||||
<div class="settings-v2-workspaces-row">
|
||||
<div class="settings-v2-workspaces-row-header">
|
||||
<div class="settings-v2-workspaces-copy">
|
||||
<div class="settings-v2-workspaces-main">
|
||||
<TooltipV2
|
||||
value={workspace.directory}
|
||||
placement="top-start"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span tabIndex={0} aria-label={workspace.directory} class="settings-v2-workspaces-path">
|
||||
{workspace.directory}
|
||||
</span>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
|
||||
</div>
|
||||
<div class="settings-v2-workspaces-row-actions">
|
||||
<Show when={lastActive(workspace)}>
|
||||
{(value) => (
|
||||
<TooltipV2
|
||||
value={language.t("settings.workspaces.lastActiveSession")}
|
||||
placement="top-end"
|
||||
>
|
||||
<span tabIndex={0} class="settings-v2-workspaces-active">
|
||||
{value()}
|
||||
</span>
|
||||
</TooltipV2>
|
||||
)}
|
||||
</Show>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
aria-label={language.t("workspace.delete.confirm", {
|
||||
name: getFilename(workspace.directory),
|
||||
})}
|
||||
disabled={!!store.transaction}
|
||||
icon={<Icon name="trash" size="small" />}
|
||||
onClick={() => confirmDelete(workspace)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={linked().length > 0}>
|
||||
<div class="settings-v2-workspaces-sessions">
|
||||
<For each={linked()}>
|
||||
{(session) => (
|
||||
<div class="settings-v2-workspaces-session">
|
||||
<span>{session.title}</span>
|
||||
<Show when={sessionTime(session)}>
|
||||
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SettingsListV2>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const remove = () => {
|
||||
const deleting = props.onDelete()
|
||||
dialog.close()
|
||||
void deleting
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog fit>
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup
|
||||
title={language.t("settings.workspaces.deleteAll")}
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
|
||||
<br />
|
||||
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 type="button" variant="danger" onClick={remove}>
|
||||
{language.t("settings.workspaces.deleteAll")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDeleteWorkspace(props: {
|
||||
workspace: Workspace
|
||||
scope: ServerScope
|
||||
inspectionID: number
|
||||
inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: SessionInfo[] }>
|
||||
inspectionMessage: (result: WorkspaceDeleteInspection) => string
|
||||
onDelete: () => Promise<void>
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const status = useQuery(() => ({
|
||||
queryKey: [props.scope, pathKey(props.workspace.directory), "workspace-delete-status", props.inspectionID] as const,
|
||||
queryFn: props.inspect,
|
||||
staleTime: 0,
|
||||
}))
|
||||
const description = () => {
|
||||
if (status.isPending) return language.t("workspace.status.checking")
|
||||
if (status.isError) return language.t("workspace.status.error")
|
||||
return props.inspectionMessage(status.data?.result ?? "unknown")
|
||||
}
|
||||
const remove = () => {
|
||||
const deleting = props.onDelete()
|
||||
dialog.close()
|
||||
void deleting
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog fit>
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup
|
||||
title={language.t("workspace.delete.title")}
|
||||
description={
|
||||
<>
|
||||
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
|
||||
<br />
|
||||
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
|
||||
{props.workspace.directory}
|
||||
</code>
|
||||
<br />
|
||||
{language.t("settings.workspaces.delete.warning")}
|
||||
<br />
|
||||
{description()}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={
|
||||
status.isPending || status.isError || (status.data?.result !== "safe" && status.data?.result !== "dirty")
|
||||
}
|
||||
onClick={remove}
|
||||
>
|
||||
{language.t("workspace.delete.button")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,7 @@
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import {
|
||||
type Accessor,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
For,
|
||||
type JSXElement,
|
||||
onCleanup,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
@@ -109,7 +100,7 @@ type ServerStatusItem = {
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const global = useGlobal()
|
||||
@@ -125,10 +116,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.shown()) return
|
||||
})
|
||||
|
||||
let dialogRun = 0
|
||||
let dialogDead = false
|
||||
onCleanup(() => {
|
||||
@@ -147,7 +134,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const [pluginList] = createResource(
|
||||
() => (props.shown() ? sdk().directory : undefined),
|
||||
() => (props.shown ? sdk().directory : undefined),
|
||||
(directory) =>
|
||||
sdk()
|
||||
.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)]" />
|
||||
}
|
||||
>
|
||||
<Body shown={shown} />
|
||||
<Body shown={shown()} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
</Popover>
|
||||
@@ -114,7 +114,7 @@ function DirectoryStatusPopover() {
|
||||
onOpenChange: setShown,
|
||||
body: () => (
|
||||
<StatusPopoverBody shown={shown()}>
|
||||
<Body shown={shown} />
|
||||
<Body shown={shown()} />
|
||||
</StatusPopoverBody>
|
||||
),
|
||||
}))
|
||||
|
||||
@@ -175,6 +175,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
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 url = sdk().url
|
||||
let container!: HTMLDivElement
|
||||
|
||||
@@ -22,14 +22,14 @@ export function TabNavItem(props: {
|
||||
ref?: Ref<HTMLDivElement>
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
session: () => SessionInfo | undefined
|
||||
session: SessionInfo | undefined
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onClose: () => void
|
||||
onNavigate: () => void
|
||||
active?: boolean
|
||||
forceTruncate?: boolean
|
||||
suppressNavigation?: () => boolean
|
||||
suppressNavigation?: boolean
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
@@ -52,22 +52,22 @@ export function TabNavItem(props: {
|
||||
if (conn) return global.ensureServerCtx(conn)
|
||||
})
|
||||
const project = createMemo(() => {
|
||||
const session = props.session()
|
||||
const session = props.session
|
||||
if (!session) return
|
||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||
})
|
||||
const title = createMemo(() => {
|
||||
const session = props.session()
|
||||
const session = props.session
|
||||
return session ? sessionLabel(session) : props.fallbackTitle
|
||||
})
|
||||
|
||||
const projectName = createMemo(() => {
|
||||
const session = props.session()
|
||||
const session = props.session
|
||||
if (!session) return
|
||||
return displayName(project() ?? { worktree: session.location.directory })
|
||||
})
|
||||
const previewPath = createMemo(() => {
|
||||
const session = props.session()
|
||||
const session = props.session
|
||||
if (!session) return
|
||||
const home = serverCtx()?.sync.data.path.home
|
||||
return home ? session.location.directory.replace(home, "~") : session.location.directory
|
||||
@@ -80,7 +80,7 @@ export function TabNavItem(props: {
|
||||
})
|
||||
|
||||
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 = () => {
|
||||
if (!titleEl || editing()) {
|
||||
@@ -121,7 +121,7 @@ export function TabNavItem(props: {
|
||||
const closeRename = async (save: boolean) => {
|
||||
if (rename.isPending || !editing()) return
|
||||
|
||||
const original = props.session()?.title ?? ""
|
||||
const original = props.session?.title ?? ""
|
||||
const next = (titleEl.textContent ?? "").trim()
|
||||
|
||||
titleEl.scrollLeft = 0
|
||||
@@ -146,7 +146,7 @@ export function TabNavItem(props: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
|
||||
const session = props.session()
|
||||
const session = props.session
|
||||
if (!session) return
|
||||
titleEl.textContent = session.title ?? ""
|
||||
setEditing(true)
|
||||
@@ -213,7 +213,7 @@ export function TabNavItem(props: {
|
||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||
if (event.button !== 0) return
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
if (props.suppressNavigation) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
@@ -221,14 +221,14 @@ export function TabNavItem(props: {
|
||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||
if (event.detail > 0) return
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
if (props.suppressNavigation) return
|
||||
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]"
|
||||
>
|
||||
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
|
||||
<Show
|
||||
when={props.session()}
|
||||
when={props.session}
|
||||
keyed
|
||||
fallback={
|
||||
<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
|
||||
event.preventDefault()
|
||||
titleEl.textContent = props.session()?.title ?? ""
|
||||
titleEl.textContent = props.session?.title ?? ""
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
@@ -308,7 +308,7 @@ export function TabNavItem(props: {
|
||||
}}
|
||||
data={{
|
||||
projectName: projectName(),
|
||||
title: props.session()?.title,
|
||||
title: props.session?.title,
|
||||
path: previewPath(),
|
||||
serverName: serverLabel(),
|
||||
}}
|
||||
@@ -323,7 +323,7 @@ export function DraftTabItem(props: {
|
||||
active?: boolean
|
||||
onNavigate: () => void
|
||||
onClose: () => void
|
||||
suppressNavigation?: () => boolean
|
||||
suppressNavigation?: boolean
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
@@ -366,14 +366,14 @@ export function DraftTabItem(props: {
|
||||
onMouseDown={(event) => {
|
||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||
if (event.button !== 0) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
if (props.suppressNavigation) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||
if (event.detail > 0) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
if (props.suppressNavigation) return
|
||||
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]"
|
||||
|
||||
@@ -24,10 +24,10 @@ import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
function SessionTabSlot(props: {
|
||||
tab: SessionTab
|
||||
id: string
|
||||
index: () => number
|
||||
active: () => boolean
|
||||
index: number
|
||||
active: boolean
|
||||
forceTruncate: boolean
|
||||
session: () => SessionInfo | undefined
|
||||
session: SessionInfo | undefined
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
@@ -38,7 +38,7 @@ function SessionTabSlot(props: {
|
||||
return props.id
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
return props.index
|
||||
},
|
||||
})
|
||||
let ref!: HTMLDivElement
|
||||
@@ -48,7 +48,7 @@ function SessionTabSlot(props: {
|
||||
ref={sortable.ref}
|
||||
data-titlebar-tab-slot
|
||||
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"
|
||||
>
|
||||
<TabNavItem
|
||||
@@ -62,7 +62,7 @@ function SessionTabSlot(props: {
|
||||
onRename={props.onRename}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active()}
|
||||
active={props.active}
|
||||
forceTruncate={props.forceTruncate}
|
||||
dragging={sortable.isDragSource()}
|
||||
/>
|
||||
@@ -73,34 +73,34 @@ function SessionTabSlot(props: {
|
||||
function SessionTabEntry(props: {
|
||||
tab: SessionTab
|
||||
id: string
|
||||
index: () => number
|
||||
active: () => boolean
|
||||
index: number
|
||||
active: boolean
|
||||
forceTruncate: boolean
|
||||
serverCtx: () => ServerCtx | undefined
|
||||
serverCtx: ServerCtx | undefined
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
||||
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
||||
const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
|
||||
const cachedSession = createMemo(() => props.serverCtx?.sync.session.peek(props.tab.sessionId))
|
||||
const persisted = createMemo(() => tabs.info[props.id])
|
||||
const [loadedSession] = createResource(
|
||||
() => {
|
||||
const ctx = props.serverCtx()
|
||||
const ctx = props.serverCtx
|
||||
return ctx ? { id: props.tab.sessionId, ctx } : null
|
||||
},
|
||||
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
||||
)
|
||||
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)
|
||||
let prefetched = false
|
||||
|
||||
const rename = async (title: string) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
const ctx = props.serverCtx
|
||||
if (!value || !ctx) return
|
||||
|
||||
ctx.sync.session.remember({ ...value, title })
|
||||
@@ -108,7 +108,7 @@ function SessionTabEntry(props: {
|
||||
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
||||
} catch (err) {
|
||||
const current = session()
|
||||
const currentCtx = props.serverCtx()
|
||||
const currentCtx = props.serverCtx
|
||||
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
@@ -120,7 +120,7 @@ function SessionTabEntry(props: {
|
||||
createEffect(() => props.onVisibleChange(visible()))
|
||||
|
||||
createEffect(() => {
|
||||
const ctx = props.serverCtx()
|
||||
const ctx = props.serverCtx
|
||||
const value = session()
|
||||
if (!ctx || !value || prefetched) return
|
||||
prefetched = true
|
||||
@@ -157,7 +157,7 @@ function SessionTabEntry(props: {
|
||||
index={props.index}
|
||||
active={props.active}
|
||||
forceTruncate={props.forceTruncate}
|
||||
session={session}
|
||||
session={session()}
|
||||
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
|
||||
onRename={rename}
|
||||
onNavigate={props.onNavigate}
|
||||
@@ -170,8 +170,8 @@ function SessionTabEntry(props: {
|
||||
function DraftTabSlot(props: {
|
||||
tab: Extract<Tab, { type: "draft" }>
|
||||
id: string
|
||||
index: () => number
|
||||
active: () => boolean
|
||||
index: number
|
||||
active: boolean
|
||||
title: string
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
@@ -181,7 +181,7 @@ function DraftTabSlot(props: {
|
||||
return props.id
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
return props.index
|
||||
},
|
||||
})
|
||||
let ref!: HTMLDivElement
|
||||
@@ -191,7 +191,7 @@ function DraftTabSlot(props: {
|
||||
ref={sortable.ref}
|
||||
data-titlebar-tab-slot
|
||||
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"
|
||||
>
|
||||
<DraftTabItem
|
||||
@@ -202,7 +202,7 @@ function DraftTabSlot(props: {
|
||||
title={props.title}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active()}
|
||||
active={props.active}
|
||||
dragging={sortable.isDragSource()}
|
||||
/>
|
||||
</div>
|
||||
@@ -211,7 +211,7 @@ function DraftTabSlot(props: {
|
||||
|
||||
export function TitlebarTabStrip(props: {
|
||||
tabs: Tab[]
|
||||
currentTab: () => Tab | undefined
|
||||
currentTab: Tab | undefined
|
||||
forceTruncate: boolean
|
||||
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
||||
onClose: (tab: Tab) => void
|
||||
@@ -248,7 +248,7 @@ export function TitlebarTabStrip(props: {
|
||||
])
|
||||
|
||||
function selectAdjacentTab(offset: -1 | 1) {
|
||||
const current = props.currentTab()
|
||||
const current = props.currentTab
|
||||
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
|
||||
const next = props.tabs.find((tab) => tabKey(tab) === key)
|
||||
if (next) props.onNavigate(next)
|
||||
@@ -350,10 +350,10 @@ export function TitlebarTabStrip(props: {
|
||||
<SessionTabEntry
|
||||
tab={tab}
|
||||
id={id}
|
||||
index={visibleIndex}
|
||||
active={() => props.currentTab() === tab}
|
||||
index={visibleIndex()}
|
||||
active={props.currentTab === tab}
|
||||
forceTruncate={props.forceTruncate}
|
||||
serverCtx={serverCtx}
|
||||
serverCtx={serverCtx()}
|
||||
onVisibleChange={(visible) => setVisibility(id, visible)}
|
||||
onNavigate={(element) => {
|
||||
ref = element
|
||||
@@ -368,8 +368,8 @@ export function TitlebarTabStrip(props: {
|
||||
<DraftTabSlot
|
||||
tab={tab}
|
||||
id={id}
|
||||
index={visibleIndex}
|
||||
active={() => props.currentTab() === tab}
|
||||
index={visibleIndex()}
|
||||
active={props.currentTab === tab}
|
||||
title={language.t("command.session.new")}
|
||||
onNavigate={(element) => {
|
||||
ref = element
|
||||
|
||||
@@ -46,8 +46,8 @@ const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px
|
||||
const macTrafficLightsBaseWidth = 84
|
||||
|
||||
export type TitlebarUpdate = {
|
||||
version: () => string | undefined
|
||||
installing: () => boolean
|
||||
version: string | undefined
|
||||
installing: boolean
|
||||
install: () => void
|
||||
}
|
||||
|
||||
@@ -121,8 +121,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
|
||||
const updateState = createMemo<TitlebarUpdatePillState>(() => {
|
||||
const installing = props.update?.installing() ?? false
|
||||
const version = props.update?.version()
|
||||
const installing = props.update?.installing ?? false
|
||||
const version = props.update?.version
|
||||
return {
|
||||
visible: version !== undefined || installing,
|
||||
installing,
|
||||
@@ -392,7 +392,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
|
||||
<TitlebarTabStrip
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab}
|
||||
currentTab={currentTab()}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
onOverflowChange={setTabsAreOverflowing}
|
||||
onNavigate={(tab, el) => {
|
||||
@@ -657,12 +657,10 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{["local", "beta", "dev"].includes(channel) && (
|
||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||
{channel.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<Show when={["local", "beta", "dev"].includes(channel)}>
|
||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||
{channel.toUpperCase()}
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show, type JSX } from "solid-js"
|
||||
import { For, Show, type JSX } from "solid-js"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -50,7 +50,20 @@ export function WindowsAppMenu(props: {
|
||||
|
||||
return (
|
||||
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
|
||||
{props.variant === "v2" ? (
|
||||
<Show
|
||||
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
|
||||
data-component="desktop-icon-button"
|
||||
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
|
||||
@@ -65,39 +78,31 @@ export function WindowsAppMenu(props: {
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="desktop-app-menu">
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
|
||||
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
|
||||
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
|
||||
{menu.items
|
||||
?.filter((entry) => desktopMenuVisible(entry, "windows"))
|
||||
.map((entry) =>
|
||||
entry.type === "separator" ? (
|
||||
<DropdownMenu.Separator />
|
||||
) : (
|
||||
<DesktopMenuItem
|
||||
label={entry.labelKey ? language.t(entry.labelKey) : ""}
|
||||
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
|
||||
disabled={entry.command ? commandDisabled(entry.command) : false}
|
||||
onSelect={() => runEntry(entry)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</DesktopMenuSubmenu>
|
||||
))}
|
||||
<For each={DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows"))}>
|
||||
{(menu) => (
|
||||
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
|
||||
<For each={menu.items?.filter((entry) => desktopMenuVisible(entry, "windows"))}>
|
||||
{(entry) => {
|
||||
// Static menu data: an early return keeps the union narrowing a Show fallback would lose.
|
||||
if (entry.type === "separator") return <DropdownMenu.Separator />
|
||||
return (
|
||||
<DesktopMenuItem
|
||||
label={entry.labelKey ? language.t(entry.labelKey) : ""}
|
||||
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
|
||||
disabled={entry.command ? commandDisabled(entry.command) : false}
|
||||
onSelect={() => runEntry(entry)}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</DesktopMenuSubmenu>
|
||||
)}
|
||||
</For>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
|
||||
const refresh: string[] = []
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/new.ts",
|
||||
event: "add",
|
||||
@@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/open.ts",
|
||||
event: "change",
|
||||
@@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src",
|
||||
event: "change",
|
||||
@@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/file.ts",
|
||||
event: "change",
|
||||
@@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: ".git/index.lock",
|
||||
event: "change",
|
||||
|
||||
@@ -16,7 +16,7 @@ type WatcherOps = {
|
||||
}
|
||||
|
||||
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||
if (event.type !== "file.watcher.updated") return
|
||||
if (event.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
||||
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
||||
|
||||
@@ -106,16 +106,26 @@ describe("query keys", () => {
|
||||
})
|
||||
|
||||
test("loads projects from the current endpoint", async () => {
|
||||
const calls: string[] = []
|
||||
const api = {
|
||||
list: async () => [
|
||||
{ id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
],
|
||||
directories: async ({ projectID }: { projectID: string }) => {
|
||||
calls.push(projectID)
|
||||
return [
|
||||
{ directory: `/${projectID}` },
|
||||
{ directory: `/${projectID}/copy`, strategy: "git_worktree" },
|
||||
]
|
||||
},
|
||||
} as unknown as ProjectApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
|
||||
|
||||
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||
expect(result.map((project) => project.sandboxes)).toEqual([["/a/copy"], ["/b/copy"]])
|
||||
expect(calls.toSorted()).toEqual(["a", "b"])
|
||||
})
|
||||
|
||||
test("loads references from the current location-scoped endpoint", async () => {
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
CommandInfo,
|
||||
CommandListInput,
|
||||
CommandListOutput,
|
||||
IntegrationListInput,
|
||||
IntegrationListOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
PermissionRequest,
|
||||
@@ -26,7 +28,7 @@ import { batch } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import type { ServerSession } from "../server-session"
|
||||
import { cmp, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
|
||||
import { cmp, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||
@@ -103,6 +105,7 @@ export const loadGlobalConfigQuery = (scope: ServerScope) =>
|
||||
type ProjectApi = {
|
||||
readonly list: () => Promise<ProjectListOutput>
|
||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||
readonly directories: ServerApi["project"]["directories"]
|
||||
}
|
||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||
|
||||
@@ -116,10 +119,16 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
||||
queryKey: [scope, "project"],
|
||||
queryFn: () =>
|
||||
retry(() =>
|
||||
api.list().then((projects) => {
|
||||
return projects
|
||||
.filter((p) => !!p?.id)
|
||||
.map(normalizeProjectInfo)
|
||||
api.list().then(async (projects) => {
|
||||
return (await Promise.all(
|
||||
projects.filter((project) => !!project?.id).map(async (project) => {
|
||||
const directories = await api.directories({ projectID: project.id })
|
||||
return normalizeProjectInfo({
|
||||
...project,
|
||||
sandboxes: directories.filter((item) => item.strategy !== undefined).map((item) => item.directory),
|
||||
})
|
||||
}),
|
||||
))
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
@@ -221,6 +230,10 @@ type CommandListApi = {
|
||||
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
|
||||
}
|
||||
|
||||
type IntegrationListApi = {
|
||||
readonly list: (input?: IntegrationListInput) => Promise<IntegrationListOutput>
|
||||
}
|
||||
|
||||
type ReferenceListApi = {
|
||||
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
||||
}
|
||||
@@ -231,6 +244,13 @@ export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: Agen
|
||||
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[]> =>
|
||||
retry(() => api.list({ location: { directory } }).then((result) => result.data))
|
||||
|
||||
@@ -297,104 +317,109 @@ export async function bootstrapDirectory(input: {
|
||||
const revKey = ScopedKey.from(input.scope, input.directory)
|
||||
const rev = (providerRev.get(revKey) ?? 0) + 1
|
||||
providerRev.set(revKey, rev)
|
||||
;(async () => {
|
||||
const slow = [
|
||||
() => 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
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, 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
|
||||
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location))
|
||||
.then((data) => {
|
||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
})),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
|
||||
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.api.reference)),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.api.permission.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data)
|
||||
.then((permissions) => {
|
||||
const ids = permissions.map((permission) => permission.sessionID)
|
||||
const grouped = groupBySession(
|
||||
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
||||
)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.permission ?? input.store.permission
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("permission", sessionID, [])
|
||||
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" },
|
||||
)
|
||||
if (input.session) input.session.set("permission", sessionID, value)
|
||||
if (!input.session) input.setStore("permission", sessionID, value)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.api.question.request
|
||||
.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[],
|
||||
)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.question ?? input.store.question
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("question", sessionID, [])
|
||||
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" },
|
||||
)
|
||||
if (input.session) input.session.set("question", sessionID, value)
|
||||
if (!input.session) input.setStore("question", sessionID, value)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
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) => {
|
||||
.ensureQueryData(loadPathQuery(input.scope, directoryKey(input.directory), input.api.location))
|
||||
.then((data) => {
|
||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
})),
|
||||
input.mcp &&
|
||||
(() => loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadReferencesQuery(input.scope, directoryKey(input.directory), input.api.reference),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.api.permission.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data)
|
||||
.then((permissions) => {
|
||||
const ids = permissions.map((permission) => permission.sessionID)
|
||||
const grouped = groupBySession(
|
||||
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
||||
)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.permission ?? input.store.permission
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("permission", sessionID, [])
|
||||
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" },
|
||||
)
|
||||
if (input.session) input.session.set("permission", sessionID, value)
|
||||
if (!input.session) input.setStore("permission", sessionID, value)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.api.question.request
|
||||
.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[],
|
||||
)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.question ?? input.store.question
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("question", sessionID, [])
|
||||
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" },
|
||||
)
|
||||
if (input.session) input.session.set("question", sessionID, value)
|
||||
if (!input.session) input.setStore("question", sessionID, value)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
input.mcp &&
|
||||
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
||||
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))
|
||||
.catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
@@ -402,20 +427,19 @@ export async function bootstrapDirectory(input: {
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
}),
|
||||
].filter(Boolean) as (() => Promise<any>)[]
|
||||
].filter(Boolean) as (() => Promise<any>)[]
|
||||
|
||||
await waitForPaint()
|
||||
const slowErrs = errors(await runAll(slow))
|
||||
if (slowErrs.length > 0) {
|
||||
console.error("Failed to finish bootstrap instance", slowErrs[0])
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(slowErrs[0], input.translate),
|
||||
})
|
||||
}
|
||||
await waitForPaint()
|
||||
const slowErrs = errors(await runAll(slow))
|
||||
if (slowErrs.length > 0) {
|
||||
console.error("Failed to finish bootstrap instance", slowErrs[0])
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
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,6 +8,7 @@ import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
||||
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
|
||||
let providerQuerySuccess = true
|
||||
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
|
||||
store[0],
|
||||
store[1],
|
||||
@@ -58,11 +59,17 @@ beforeAll(async () => {
|
||||
get isLoading() {
|
||||
return options().queryKey?.[1] === "path"
|
||||
},
|
||||
get isSuccess() {
|
||||
return options().queryKey?.[1] === "providers" && options().enabled === true && providerQuerySuccess
|
||||
},
|
||||
get isRefetchError() {
|
||||
return false
|
||||
},
|
||||
get data() {
|
||||
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] === "lsp") return []
|
||||
if (options().queryKey?.[1] === "providers") return provider
|
||||
if (options().queryKey?.[1] === "providers") return providerQuerySuccess ? provider : undefined
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
@@ -83,6 +90,7 @@ describe("createChildStoreManager", () => {
|
||||
|
||||
const manager = createChildStoreManager({
|
||||
owner,
|
||||
connected: () => true,
|
||||
scope: ServerScope.local,
|
||||
persist,
|
||||
isBooting: () => false,
|
||||
@@ -114,6 +122,7 @@ describe("createChildStoreManager", () => {
|
||||
const dispose = createOwner((owner) => {
|
||||
manager = createChildStoreManager({
|
||||
owner,
|
||||
connected: () => true,
|
||||
scope: ServerScope.local,
|
||||
persist,
|
||||
isBooting: () => false,
|
||||
@@ -148,6 +157,7 @@ describe("createChildStoreManager", () => {
|
||||
const dispose = createOwner((owner) => {
|
||||
manager = createChildStoreManager({
|
||||
owner,
|
||||
connected: () => true,
|
||||
scope: ServerScope.local,
|
||||
persist,
|
||||
isBooting: () => false,
|
||||
@@ -181,6 +191,7 @@ describe("createChildStoreManager", () => {
|
||||
const dispose = createOwner((owner) => {
|
||||
manager = createChildStoreManager({
|
||||
owner,
|
||||
connected: () => true,
|
||||
scope: ServerScope.local,
|
||||
persist,
|
||||
isBooting: () => false,
|
||||
@@ -230,6 +241,7 @@ describe("createChildStoreManager", () => {
|
||||
const dispose = createOwner((owner) => {
|
||||
manager = createChildStoreManager({
|
||||
owner,
|
||||
connected: () => true,
|
||||
scope: ServerScope.local,
|
||||
persist,
|
||||
isBooting: () => false,
|
||||
@@ -273,4 +285,71 @@ describe("createChildStoreManager", () => {
|
||||
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 Owner } from "solid-js"
|
||||
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { VcsInfo } from "@/types"
|
||||
@@ -22,6 +22,7 @@ import type { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
export function createChildStoreManager(input: {
|
||||
owner: Owner
|
||||
connected: Accessor<boolean>
|
||||
scope: ServerScope
|
||||
persist: typeof persisted
|
||||
isBooting: (directory: string) => boolean
|
||||
@@ -188,17 +189,29 @@ export function createChildStoreManager(input: {
|
||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
||||
|
||||
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
||||
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
||||
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
|
||||
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
|
||||
const pathQuery = useQuery(() => ({
|
||||
...input.queryOptions.path(key),
|
||||
enabled: input.connected() && 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(() => ({
|
||||
...input.queryOptions.providers(key),
|
||||
enabled: instanceQueriesEnabled(),
|
||||
enabled: input.connected() && instanceQueriesEnabled(),
|
||||
}))
|
||||
const referenceQuery = useQuery(() => ({
|
||||
...input.queryOptions.references(key),
|
||||
enabled: instanceQueriesEnabled(),
|
||||
enabled: input.connected() && instanceQueriesEnabled(),
|
||||
}))
|
||||
|
||||
const child = createStore<State>({
|
||||
@@ -206,13 +219,14 @@ export function createChildStoreManager(input: {
|
||||
projectMeta: initialMeta,
|
||||
icon: initialIcon,
|
||||
get provider_ready() {
|
||||
return instanceQueriesEnabled() && !providerQuery.isLoading
|
||||
return instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError)
|
||||
},
|
||||
get provider() {
|
||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||
if (providerQuery.isLoading) return EMPTY
|
||||
if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
|
||||
return providerQuery.data ?? EMPTY
|
||||
if (!providerQuery.isSuccess && !providerQuery.isRefetchError) return EMPTY
|
||||
const provider = providerQuery.data
|
||||
if (provider.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
|
||||
return provider
|
||||
},
|
||||
config: {},
|
||||
get path() {
|
||||
|
||||
@@ -120,7 +120,7 @@ describe("applyGlobalEvent", () => {
|
||||
expect(refreshCount).toBe(1)
|
||||
})
|
||||
|
||||
test("handles server.connected by triggering refresh", () => {
|
||||
test("leaves server.connected refresh to the connection sync", () => {
|
||||
let refreshCount = 0
|
||||
applyGlobalEvent({
|
||||
event: { type: "server.connected" },
|
||||
@@ -131,7 +131,7 @@ describe("applyGlobalEvent", () => {
|
||||
setGlobalProject() {},
|
||||
})
|
||||
|
||||
expect(refreshCount).toBe(1)
|
||||
expect(refreshCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 type { Message, Part, Project, Todo } from "@/types"
|
||||
import type {
|
||||
@@ -37,10 +38,11 @@ export function applyGlobalEvent(input: {
|
||||
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
|
||||
refresh: () => void
|
||||
}) {
|
||||
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
|
||||
if (input.event.type === "global.disposed") {
|
||||
input.refresh()
|
||||
return
|
||||
}
|
||||
if (input.event.type === "server.connected") return
|
||||
|
||||
if (input.event.type !== "project.updated") return
|
||||
const properties = input.event.properties as Project
|
||||
@@ -187,6 +189,18 @@ export function applyDirectoryEvent(input: {
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
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": {
|
||||
const properties = event.properties as { sessionID: string; title: string }
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, Todo } from "@/types"
|
||||
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
|
||||
@@ -34,6 +34,7 @@ describe("app session cache", () => {
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
form: Record<string, FormInfo[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
||||
@@ -44,6 +45,7 @@ describe("app session cache", () => {
|
||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||
permission: { ses_1: [] as PermissionRequest[] },
|
||||
question: { ses_1: [] as QuestionRequest[] },
|
||||
form: { ses_1: [] as FormInfo[] },
|
||||
part_text_accum_delta: { prt_1: "streamed text" },
|
||||
}
|
||||
|
||||
@@ -57,6 +59,7 @@ describe("app session cache", () => {
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
expect(store.permission.ses_1).toBeUndefined()
|
||||
expect(store.question.ses_1).toBeUndefined()
|
||||
expect(store.form.ses_1).toBeUndefined()
|
||||
})
|
||||
|
||||
test("dropSessionCaches clears message-backed parts", () => {
|
||||
@@ -70,6 +73,7 @@ describe("app session cache", () => {
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
form: Record<string, FormInfo[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
session_status: {},
|
||||
@@ -80,6 +84,7 @@ describe("app session cache", () => {
|
||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||
permission: {},
|
||||
question: {},
|
||||
form: {},
|
||||
part_text_accum_delta: {},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Message, Part, Todo } from "@/types"
|
||||
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
@@ -14,6 +14,7 @@ type SessionCache = {
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
form?: Record<string, FormInfo[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
@@ -38,6 +39,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
||||
delete store.session_status[sessionID]
|
||||
delete store.permission[sessionID]
|
||||
delete store.question[sessionID]
|
||||
if (store.form) delete store.form[sessionID]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useQueryClient } from "@tanstack/solid-query"
|
||||
import { createEffect, createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
||||
@@ -7,7 +8,6 @@ import { useServerHealth } from "@/utils/server-health"
|
||||
import { createServerSdkContext } from "./server-sdk"
|
||||
import { createServerSyncContext } from "./server-sync"
|
||||
import { getOwner } from "solid-js/web"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
@@ -98,17 +98,9 @@ function createServerCtx(
|
||||
scope: ServerScope,
|
||||
projects: ReturnType<typeof createServerProjects>,
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const sdk = createServerSdkContext(conn, scope)
|
||||
const sync = createServerSyncContext(sdk)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||
const [childStore] = sync.child(project.worktree, { bootstrap: false })
|
||||
@@ -141,10 +133,10 @@ function createServerCtx(
|
||||
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||
|
||||
return {
|
||||
queryClient,
|
||||
sdk,
|
||||
sync,
|
||||
isLocal,
|
||||
queryClient,
|
||||
projects: {
|
||||
...projects,
|
||||
list: projectsList,
|
||||
|
||||
@@ -2,7 +2,12 @@ import * as i18n from "@solid-primitives/i18n"
|
||||
import { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
|
||||
import {
|
||||
pluralCategory,
|
||||
type UiI18nPluralLookupKey,
|
||||
type UiI18nPluralKey,
|
||||
type UiPluralCategory,
|
||||
} from "@opencode-ai/ui/context/i18n"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||
@@ -28,11 +33,13 @@ function localeDirection(locale: Locale): Direction {
|
||||
|
||||
type RawDictionary = typeof en & typeof uiEn
|
||||
type Dictionary = i18n.Flatten<RawDictionary>
|
||||
type PluralKey =
|
||||
| UiI18nPluralKey
|
||||
| "session.question.pending"
|
||||
| "session.followupDock.summary"
|
||||
| "session.revertDock.summary"
|
||||
type AppI18nKey = Extract<keyof typeof en, string>
|
||||
type AppI18nPluralKey = {
|
||||
[Key in AppI18nKey]: Key extends `${infer Base}.other` ? (`${Base}.one` extends AppI18nKey ? Base : never) : never
|
||||
}[AppI18nKey]
|
||||
type PluralKey = AppI18nPluralKey | UiI18nPluralKey
|
||||
type AppI18nPluralLookupKey = `${AppI18nPluralKey}.${UiPluralCategory}`
|
||||
type TranslationKey<Key extends string> = Key extends AppI18nPluralLookupKey | UiI18nPluralLookupKey ? never : Key
|
||||
type Source = { dict: Record<string, string> }
|
||||
|
||||
function cookie(locale: Locale) {
|
||||
@@ -189,18 +196,23 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
initialValue: dicts.get(initial) ?? base,
|
||||
})
|
||||
|
||||
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
|
||||
key: keyof Dictionary,
|
||||
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as <Key extends string>(
|
||||
key: TranslationKey<Key>,
|
||||
params?: Record<string, string | number | boolean>,
|
||||
) => string
|
||||
|
||||
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
|
||||
const category = pluralCategory(intl(), count)
|
||||
const pluralForm = (
|
||||
key: PluralKey,
|
||||
category: UiPluralCategory,
|
||||
params?: Record<string, string | number | boolean>,
|
||||
) => {
|
||||
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
|
||||
const candidate = `${key}.${category}`
|
||||
const fallback = `${key}.other`
|
||||
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count })
|
||||
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, params)
|
||||
}
|
||||
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) =>
|
||||
pluralForm(key, pluralCategory(intl(), count), { ...params, count })
|
||||
|
||||
const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value]
|
||||
|
||||
@@ -231,6 +243,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
label,
|
||||
t,
|
||||
plural,
|
||||
pluralForm,
|
||||
setLocale(next: Locale) {
|
||||
setStore("locale", normalizeLocale(next))
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Accessor, createMemo, createResource } from "solid-js"
|
||||
import { createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DateTime } from "luxon"
|
||||
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({
|
||||
name: "Models",
|
||||
gate: false,
|
||||
init: (props: { directory?: Accessor<string | undefined> } = {}) => {
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
init: (props: { directory?: string } = {}) => {
|
||||
const providers = useProviders(() => props.directory)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("model", ["model.v1"]),
|
||||
|
||||
@@ -398,15 +398,20 @@ function createServerNotificationState(input: {
|
||||
|
||||
const unsub = serverSDK().event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event.type !== "session.idle" && event.type !== "session.execution.failed") return
|
||||
if (
|
||||
event.type !== "session.execution.succeeded" &&
|
||||
event.type !== "session.execution.interrupted" &&
|
||||
event.type !== "session.execution.failed"
|
||||
)
|
||||
return
|
||||
|
||||
const directory = e.name
|
||||
const time = Date.now()
|
||||
if (event.type === "session.idle") {
|
||||
handleSessionIdle(directory, event, time)
|
||||
if (event.type === "session.execution.failed") {
|
||||
handleSessionError(directory, event, time)
|
||||
return
|
||||
}
|
||||
handleSessionError(directory, event, time)
|
||||
handleSessionIdle(directory, event, time)
|
||||
})
|
||||
onCleanup(() => {
|
||||
meta.disposed = true
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk"
|
||||
import { useSettings } from "./settings"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useTabs, type Tab } from "./tabs"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import {
|
||||
createPromptReady,
|
||||
createPromptSession,
|
||||
@@ -104,11 +105,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
||||
const scope = (): PromptScope =>
|
||||
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||
const load = (scope: PromptScope) => {
|
||||
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
||||
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
||||
const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => {
|
||||
const current = settings.general.newLayoutDesigns()
|
||||
? selectPromptTab(tabs.store, scope, target?.server ?? serverKey())
|
||||
: undefined
|
||||
if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK().scope, scope)
|
||||
|
||||
const key = scopeKey(scope)
|
||||
const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope)
|
||||
const existing = cache.get(key)
|
||||
if (existing) {
|
||||
cache.delete(key)
|
||||
@@ -118,7 +121,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
|
||||
const entry = createRoot(
|
||||
(dispose) => ({
|
||||
value: createPromptSession(serverSDK().scope, scope),
|
||||
value: createPromptSession(target?.scope ?? serverSDK().scope, scope),
|
||||
dispose,
|
||||
}),
|
||||
owner,
|
||||
@@ -130,7 +133,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const session = createMemo(() => load(scope()))
|
||||
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
|
||||
const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||
scope ? load(scope, target) : session()
|
||||
const ready = createPromptReady(session)
|
||||
|
||||
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
||||
@@ -146,7 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
|
||||
return {
|
||||
ready,
|
||||
capture: (scope?: PromptScope) => pick(scope).capture(),
|
||||
capture: (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||
pick(scope, target).capture(),
|
||||
current: withSuspense(() => session().current()),
|
||||
cursor: withSuspense(() => session().cursor()),
|
||||
dirty: withSuspense(() => session().dirty()),
|
||||
|
||||
@@ -6,12 +6,9 @@ export type { DirectorySDK } from "./server-sdk"
|
||||
const context = createSimpleContext({
|
||||
name: "SDK",
|
||||
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
|
||||
init: (props: { directory: string | Accessor<string> }) => {
|
||||
init: (props: { directory: string }) => {
|
||||
const serverSDK = useServerSDK()
|
||||
return createMemo(() => {
|
||||
const directory = typeof props.directory === "function" ? props.directory() : props.directory
|
||||
return serverSDK().ensureDirSdkContext(directory)
|
||||
})
|
||||
return createMemo(() => serverSDK().ensureDirSdkContext(props.directory))
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import {
|
||||
adaptServerEvent,
|
||||
applyWorkspaceOperationEvent,
|
||||
coalesceServerEvents,
|
||||
enqueueServerEvent,
|
||||
resumeStreamAfterPageShow,
|
||||
} from "./server-sdk"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
|
||||
test("a moved event completes the matching workspace operation", () => {
|
||||
WorkspaceOperation.start(ServerScope.local, "current", "move", "/workspace")
|
||||
applyWorkspaceOperationEvent(ServerScope.local, {
|
||||
directory: "/workspace",
|
||||
payload: adaptServerEvent({
|
||||
id: "moved-current",
|
||||
created: Date.now(),
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: "current", seq: 1, version: 1 },
|
||||
data: { sessionID: "current", location: { directory: "/workspace" }, projectID: "test-project" },
|
||||
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>),
|
||||
})
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "current")?.status).toBe("complete")
|
||||
})
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
test("restarts a stream only after a back-forward cache restore", () => {
|
||||
@@ -72,7 +95,7 @@ describe("current event buffering", () => {
|
||||
type: "session.tool.input.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
|
||||
} as OpenCodeEvent)
|
||||
} satisfies Extract<OpenCodeEvent, { type: "session.tool.input.delta" }>)
|
||||
const result = coalesceServerEvents([
|
||||
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
|
||||
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createApiForServer, type ServerApi } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
@@ -11,11 +12,8 @@ import { ServerConnection, useServer } from "./server"
|
||||
import { createRefCountMap } from "@/utils/refcount"
|
||||
import { useGlobal } from "./global"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
|
||||
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 }
|
||||
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
||||
type CurrentDelta = Extract<
|
||||
@@ -68,6 +66,16 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
return output
|
||||
}
|
||||
|
||||
export function applyWorkspaceOperationEvent(scope: ServerScope, event: QueuedServerEvent) {
|
||||
if (event.payload.current?.type !== "session.moved") return false
|
||||
WorkspaceOperation.complete(
|
||||
scope,
|
||||
event.payload.current.data.sessionID,
|
||||
event.payload.current.data.location.directory,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||
if (
|
||||
event?.type === "session.text.delta" ||
|
||||
@@ -95,15 +103,20 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
|
||||
}
|
||||
|
||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
||||
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
|
||||
type ServerSDKBase = {
|
||||
server: ServerConnection.Any
|
||||
scope: ServerScope
|
||||
url: string
|
||||
api: ServerApi
|
||||
connection: {
|
||||
status: Accessor<ServerConnectionStatus>
|
||||
attempt: Accessor<number>
|
||||
error: Accessor<string | undefined>
|
||||
}
|
||||
event: {
|
||||
on: ServerEventEmitter["on"]
|
||||
listen: ServerEventEmitter["listen"]
|
||||
start: () => Promise<void> | undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +143,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
type Queued = QueuedServerEvent
|
||||
const FLUSH_FRAME_MS = 16
|
||||
const STREAM_YIELD_MS = 8
|
||||
const RECONNECT_DELAY_MS = 250
|
||||
const CONNECT_TIMEOUT_MS = 2_000
|
||||
const RECONNECT_DELAY_MS = 1_000
|
||||
|
||||
let queue: Queued[] = []
|
||||
let buffer: Queued[] = []
|
||||
@@ -151,7 +165,10 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
last = Date.now()
|
||||
const output = coalesceServerEvents(events)
|
||||
batch(() => {
|
||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||
output.forEach((event) => {
|
||||
applyWorkspaceOperationEvent(scope, event)
|
||||
emitter.emit(event.directory, event.payload)
|
||||
})
|
||||
})
|
||||
|
||||
buffer.length = 0
|
||||
@@ -163,12 +180,99 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
|
||||
}
|
||||
|
||||
let streamErrorLogged = false
|
||||
const publish = (event: OpenCodeEvent) => {
|
||||
const directory = event.location?.directory ?? "global"
|
||||
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
let attempt: AbortController | undefined
|
||||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
let generation = 0
|
||||
const [connection, setConnection] = createStore<{
|
||||
status: ServerConnectionStatus
|
||||
attempt: number
|
||||
error?: string
|
||||
}>({ status: "connecting", attempt: 0 })
|
||||
|
||||
const connect = async (signal: AbortSignal): Promise<{ error: unknown; connectedAt: number | undefined }> => {
|
||||
let connectedAt: number | undefined
|
||||
|
||||
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 {
|
||||
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 }
|
||||
|
||||
clearTimeout(timeout)
|
||||
publish(first.value)
|
||||
connectedAt = Date.now()
|
||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||
|
||||
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)
|
||||
}
|
||||
return { error: undefined, connectedAt }
|
||||
} catch (error) {
|
||||
return { error, connectedAt }
|
||||
} finally {
|
||||
request.abort()
|
||||
clearTimeout(timeout)
|
||||
signal.removeEventListener("abort", cancel)
|
||||
}
|
||||
}
|
||||
|
||||
const main = async (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 })
|
||||
attempt = new AbortController()
|
||||
const onAbort = () => attempt?.abort()
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
const result = await connect(attempt.signal)
|
||||
abort.signal.removeEventListener("abort", onAbort)
|
||||
attempt = undefined
|
||||
|
||||
if (abort.signal.aborted || !started || generation !== active) 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)
|
||||
}
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (started) return run
|
||||
@@ -177,43 +281,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
const previous = run
|
||||
const current = (async () => {
|
||||
if (previous) await previous
|
||||
// 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)
|
||||
}
|
||||
await main(active)
|
||||
})().finally(() => {
|
||||
if (run !== current) return
|
||||
run = undefined
|
||||
@@ -232,6 +300,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
onMount(() => {
|
||||
makeEventListener(window, "pagehide", stop)
|
||||
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
|
||||
void start()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -247,10 +316,14 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
scope,
|
||||
url: server.http.url,
|
||||
api,
|
||||
connection: {
|
||||
status: () => connection.status,
|
||||
attempt: () => connection.attempt,
|
||||
error: () => connection.error,
|
||||
},
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
listen: emitter.listen.bind(emitter),
|
||||
start,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -270,13 +343,13 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
||||
name: "ServerSDK",
|
||||
// Returns an accessor so the resolved server can change reactively (e.g. a
|
||||
// /new-session draft retargeting its server) without re-instantiating the subtree.
|
||||
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
|
||||
init: (props: { server?: ServerConnection.Any }) => {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
|
||||
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"))
|
||||
return global.ensureServerCtx(conn).sdk
|
||||
})
|
||||
|
||||
@@ -18,18 +18,18 @@ describe("v2 session reducer", () => {
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.input.admitted",
|
||||
type: "session.inbox.enqueued",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_user",
|
||||
input: { type: "user", delivery: "steer", data: { text: "hello" } },
|
||||
inboxID: "msg_user",
|
||||
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_1", inboxID: "msg_user" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
@@ -203,14 +203,37 @@ describe("v2 session reducer", () => {
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_1", inboxID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
||||
})
|
||||
|
||||
test("projects non-initial 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" } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result?.messages).toEqual([
|
||||
{
|
||||
id: "msg_instructions",
|
||||
type: "system",
|
||||
text: "Instructions updated: agents",
|
||||
metadata: undefined,
|
||||
time: { created: 1 },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("removes cancelled input from the pending promotion fold", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
reducer.reduce(
|
||||
@@ -218,11 +241,11 @@ describe("v2 session reducer", () => {
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.input.admitted",
|
||||
type: "session.inbox.enqueued",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_user",
|
||||
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
|
||||
inboxID: "msg_user",
|
||||
item: { type: "user", delivery: "queue", payload: { text: "cancel me" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -231,8 +254,8 @@ describe("v2 session reducer", () => {
|
||||
event({
|
||||
...base,
|
||||
id: "evt_cancelled",
|
||||
type: "session.input.cancelled",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
type: "session.inbox.cancelled",
|
||||
data: { sessionID: "ses_1", inboxID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
const result = reducer.reduce(
|
||||
@@ -240,8 +263,8 @@ describe("v2 session reducer", () => {
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_1", inboxID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -255,11 +278,11 @@ describe("v2 session reducer", () => {
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.input.admitted",
|
||||
type: "session.inbox.enqueued",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_user",
|
||||
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
|
||||
inboxID: "msg_user",
|
||||
item: { type: "user", delivery: "queue", payload: { text: "steer me" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -268,8 +291,8 @@ describe("v2 session reducer", () => {
|
||||
event({
|
||||
...base,
|
||||
id: "evt_steered",
|
||||
type: "session.input.steered",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
type: "session.inbox.delivery.changed",
|
||||
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "steer" },
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
@@ -277,8 +300,8 @@ describe("v2 session reducer", () => {
|
||||
event({
|
||||
...base,
|
||||
id: "evt_queued",
|
||||
type: "session.input.queued",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
type: "session.inbox.delivery.changed",
|
||||
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "queue" },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -287,8 +310,8 @@ describe("v2 session reducer", () => {
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_1", inboxID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise"
|
||||
import type { OpenCodeEvent, SessionInboxItem, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
|
||||
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
|
||||
@@ -8,11 +8,12 @@ export type V2SessionReduction = {
|
||||
sessionID: string
|
||||
messages: SessionMessageInfo[]
|
||||
touched: string[]
|
||||
removed?: string[]
|
||||
missing?: string
|
||||
}
|
||||
|
||||
export function createV2SessionReducer() {
|
||||
const pending = new Map<string, SessionPendingMessage>()
|
||||
const pending = new Map<string, SessionInboxItem>()
|
||||
|
||||
const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => {
|
||||
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
|
||||
@@ -26,32 +27,59 @@ export function createV2SessionReducer() {
|
||||
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
|
||||
|
||||
switch (event.type) {
|
||||
case "session.input.admitted":
|
||||
pending.set(key(sessionID, event.data.inputID), event.data.input)
|
||||
return result([...source])
|
||||
case "session.input.cancelled":
|
||||
pending.delete(key(sessionID, event.data.inputID))
|
||||
return
|
||||
case "session.input.promoted": {
|
||||
const input = pending.get(key(sessionID, event.data.inputID))
|
||||
pending.delete(key(sessionID, event.data.inputID))
|
||||
if (!input) return { ...result([...source]), missing: event.data.inputID }
|
||||
if (input.type === "user")
|
||||
case "session.inbox.enqueued":
|
||||
pending.set(key(sessionID, event.data.inboxID), event.data.item)
|
||||
if (event.data.item.type === "user")
|
||||
return append({
|
||||
id: event.data.inputID,
|
||||
id: event.data.inboxID,
|
||||
type: "user",
|
||||
metadata: input.data.metadata,
|
||||
text: input.data.text,
|
||||
files: input.data.files,
|
||||
agents: input.data.agents,
|
||||
metadata: event.data.item.payload.metadata,
|
||||
text: event.data.item.payload.text,
|
||||
files: event.data.item.payload.files,
|
||||
agents: event.data.item.payload.agents,
|
||||
time: { created: event.created },
|
||||
})
|
||||
if (event.data.item.type !== "synthetic") return result([...source])
|
||||
return append({
|
||||
id: event.data.inputID,
|
||||
id: event.data.inboxID,
|
||||
type: "synthetic",
|
||||
metadata: input.data.metadata,
|
||||
text: input.data.text,
|
||||
description: input.data.description,
|
||||
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")
|
||||
return append({
|
||||
id: event.data.inboxID,
|
||||
type: "user",
|
||||
metadata: input.payload.metadata,
|
||||
text: input.payload.text,
|
||||
files: input.payload.files,
|
||||
agents: input.payload.agents,
|
||||
time: { created: event.created },
|
||||
})
|
||||
if (input.type !== "synthetic") return result([...source])
|
||||
return append({
|
||||
id: event.data.inboxID,
|
||||
type: "synthetic",
|
||||
metadata: input.payload.metadata,
|
||||
text: input.payload.text,
|
||||
description: input.payload.description,
|
||||
time: { created: event.created },
|
||||
})
|
||||
}
|
||||
@@ -83,6 +111,23 @@ export function createV2SessionReducer() {
|
||||
)?.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.instructions.updated": {
|
||||
const instructions = event.metadata?.instructions
|
||||
if (
|
||||
typeof instructions === "object" &&
|
||||
instructions !== null &&
|
||||
"initial" in instructions &&
|
||||
instructions.initial === true
|
||||
)
|
||||
return
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "system",
|
||||
text: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
}
|
||||
case "session.synthetic":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
@@ -351,7 +396,7 @@ export function createV2SessionReducer() {
|
||||
metadata: event.metadata,
|
||||
reason: event.data.reason,
|
||||
summary: "",
|
||||
recent: event.data.recent,
|
||||
recent: event.data.recent ?? "",
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.compaction.delta":
|
||||
|
||||
@@ -348,6 +348,72 @@ describe("server session", () => {
|
||||
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: []; forms: [] }>()
|
||||
const hydration = ctx.store.hydrateTransient("child", () => response.promise)
|
||||
|
||||
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: [] })
|
||||
await hydration
|
||||
|
||||
expect(ctx.store.data.pending.child).toMatchObject([{ id: "msg_input" }])
|
||||
})
|
||||
|
||||
test("resolves lineage by session ID without directory", async () => {
|
||||
const ctx = setup({ child: session("child", "root"), root: session("root") })
|
||||
|
||||
@@ -358,6 +424,23 @@ describe("server session", () => {
|
||||
expect(ctx.store.lineage.peek("child")).toEqual(result)
|
||||
})
|
||||
|
||||
test("applies moved session locations without evicting cached state", () => {
|
||||
const current = { ...session("child"), location: { directory: "/repo/worktree" } }
|
||||
const ctx = setup({ child: current })
|
||||
ctx.store.remember(current)
|
||||
|
||||
ctx.store.applyV2({
|
||||
id: "evt_moved",
|
||||
created: 2,
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
location: current.location,
|
||||
data: { sessionID: "child", location: { directory: "/repo" }, projectID: "test-project", subpath: "packages/app" },
|
||||
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>)
|
||||
|
||||
expect(ctx.store.get("child")).toMatchObject({ location: { directory: "/repo" }, subpath: "packages/app" })
|
||||
})
|
||||
|
||||
test("loads session content through the server client", async () => {
|
||||
const ctx = setup({ root: session("root") })
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
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 type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
FormInfo,
|
||||
OpenCodeEvent,
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
SessionInboxInfo,
|
||||
SessionMessageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { Message, Part, Todo } from "@/types"
|
||||
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import { batch } from "solid-js"
|
||||
@@ -16,7 +24,7 @@ type MessageApi = ServerApi["message"]
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const initialMessagePageSize = 20
|
||||
const historyMessagePageSize = 200
|
||||
const historyMessagePageSize = 50
|
||||
const sessionInfoLimit = 2_048
|
||||
const emptyIDs: ReadonlySet<string> = new Set()
|
||||
|
||||
@@ -45,6 +53,12 @@ function projectMessageSource(message: Message): SessionMessageInfo[] {
|
||||
]
|
||||
}
|
||||
|
||||
function yieldToMain() {
|
||||
const scheduler = (globalThis as { scheduler?: { yield: () => Promise<void> } }).scheduler
|
||||
if (scheduler) return scheduler.yield()
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
||||
const boundary = source.find(
|
||||
(message) =>
|
||||
@@ -191,6 +205,9 @@ export function createServerSession(
|
||||
todo: {} as Record<string, Todo[]>,
|
||||
permission: {} as Record<string, PermissionRequest[]>,
|
||||
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[]>,
|
||||
session_message: {} as Record<string, SessionMessageInfo[]>,
|
||||
part: {} as Record<string, Part[]>,
|
||||
@@ -204,6 +221,7 @@ export function createServerSession(
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||
const v2 = createV2SessionReducer()
|
||||
const transientRevision = new Map<string, number>()
|
||||
const messageLoads = new Map<string, MessageLoadState>()
|
||||
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
||||
const orphanParts = new Map<string, Set<string>>()
|
||||
@@ -241,7 +259,13 @@ export function createServerSession(
|
||||
const indexProjectedMessage = (message: Message) => {
|
||||
const current = data.session_message[message.sessionID] ?? []
|
||||
if (current.some((item) => item.id === message.id)) return
|
||||
setData("session_message", message.sessionID, reconcile([...current, ...projectMessageSource(message)]))
|
||||
const projected = projectMessageSource(message)
|
||||
const projectedIDs = new Set(projected.map((item) => item.id))
|
||||
setData(
|
||||
"session_message",
|
||||
message.sessionID,
|
||||
reconcile([...current.filter((item) => !projectedIDs.has(item.id)), ...projected]),
|
||||
)
|
||||
}
|
||||
|
||||
const remember = (session: SessionInfo) => {
|
||||
@@ -262,6 +286,9 @@ export function createServerSession(
|
||||
...Object.entries(data.question)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
...Object.entries(data.form)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
...Object.entries(data.session_status)
|
||||
.filter(([, status]) => status.type !== "idle")
|
||||
.map(([sessionID]) => sessionID),
|
||||
@@ -288,17 +315,19 @@ export function createServerSession(
|
||||
return session
|
||||
}
|
||||
|
||||
const resolve = (sessionID: string, options?: { force?: boolean }) => {
|
||||
const resolve = (sessionID: string, options?: { force?: boolean; signal?: AbortSignal }) => {
|
||||
const cached = data.info[sessionID]
|
||||
if (cached && !options?.force) return Promise.resolve(cached)
|
||||
const pending = requests.get(sessionID)
|
||||
const pending = options?.signal ? undefined : requests.get(sessionID)
|
||||
if (pending) return pending
|
||||
const active = generation(sessionID)
|
||||
const request = sessionApi.get({ sessionID })
|
||||
const request = sessionApi.get({ sessionID }, { signal: options?.signal })
|
||||
const resolved = request.then((result) => {
|
||||
if (options?.signal?.aborted) return result
|
||||
if (generations.get(sessionID) !== active) return result
|
||||
return remember(result)
|
||||
})
|
||||
if (options?.signal) return resolved
|
||||
requests.set(sessionID, resolved)
|
||||
const cleanup = () => {
|
||||
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
|
||||
@@ -506,6 +535,9 @@ export function createServerSession(
|
||||
...Object.entries(data.question)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
...Object.entries(data.form)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
...Object.entries(data.session_status)
|
||||
.filter(([, status]) => status.type !== "idle")
|
||||
.map(([sessionID]) => sessionID),
|
||||
@@ -530,6 +562,7 @@ export function createServerSession(
|
||||
if (!response.data.length) break
|
||||
}
|
||||
const response = pages.at(-1)!
|
||||
await yieldToMain()
|
||||
const source = pages.flatMap((page) => page.data).toReversed()
|
||||
const normalized = normalizeSessionMessages(sessionID, source)
|
||||
return {
|
||||
@@ -843,7 +876,7 @@ export function createServerSession(
|
||||
const projectV2 = (reduction: V2SessionReduction) => {
|
||||
reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID))
|
||||
setData("session_message", reduction.sessionID, reconcile(reduction.messages))
|
||||
if (reduction.touched.length === 0) return
|
||||
if (reduction.touched.length === 0 && !reduction.removed?.length) return
|
||||
|
||||
const touched = new Set(reduction.touched)
|
||||
let parentID: string | undefined
|
||||
@@ -860,6 +893,9 @@ export function createServerSession(
|
||||
|
||||
const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages)
|
||||
batch(() => {
|
||||
for (const messageID of reduction.removed ?? []) {
|
||||
apply({ type: "message.removed", properties: { sessionID: reduction.sessionID, messageID } })
|
||||
}
|
||||
for (const message of normalized.messages) {
|
||||
if (!touched.has(message.id)) continue
|
||||
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
|
||||
@@ -894,8 +930,65 @@ export function createServerSession(
|
||||
}
|
||||
|
||||
const applyV2 = (event: OpenCodeEvent) => {
|
||||
if (event.type === "form.created") {
|
||||
transientRevision.set(event.data.form.sessionID, (transientRevision.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") {
|
||||
transientRevision.set(event.data.sessionID, (transientRevision.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
|
||||
const sessionID = event.data.sessionID
|
||||
if (
|
||||
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.ended" ||
|
||||
event.type === "session.compaction.failed"
|
||||
)
|
||||
transientRevision.set(sessionID, (transientRevision.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 (!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))
|
||||
if (event.type === "session.compaction.ended")
|
||||
setData("pending", sessionID, (items) => items?.filter((item) => item.type !== "compaction"))
|
||||
const reduction = v2.reduce(data.session_message[sessionID] ?? [], event)
|
||||
if (reduction) {
|
||||
projectV2(reduction)
|
||||
@@ -903,6 +996,11 @@ export function createServerSession(
|
||||
}
|
||||
|
||||
const info = data.info[sessionID]
|
||||
if (event.type === "session.agent.selected" && info) remember({ ...info, agent: event.data.agent })
|
||||
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)
|
||||
remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } })
|
||||
if (event.type === "session.moved" && info)
|
||||
@@ -940,6 +1038,7 @@ export function createServerSession(
|
||||
event.type === "session.revert.committed"
|
||||
)
|
||||
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 }) => {
|
||||
@@ -1268,6 +1367,27 @@ export function createServerSession(
|
||||
},
|
||||
},
|
||||
sync,
|
||||
async hydrateTransient(
|
||||
sessionID: string,
|
||||
load: () => Promise<{ pending: SessionInboxInfo[]; forms: FormInfo[] }>,
|
||||
) {
|
||||
const revision = transientRevision.get(sessionID) ?? 0
|
||||
const result = await load()
|
||||
if ((transientRevision.get(sessionID) ?? 0) !== revision) return
|
||||
setData("pending", sessionID, reconcile(result.pending))
|
||||
setData(
|
||||
"input",
|
||||
sessionID,
|
||||
reconcile(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
|
||||
)
|
||||
setData("form", sessionID, reconcile(result.forms))
|
||||
},
|
||||
refreshPinned() {
|
||||
return Promise.all([...pinned.keys()].map((sessionID) => sync(sessionID, { force: true }))).then(() => undefined)
|
||||
},
|
||||
invalidate() {
|
||||
setMeta("at", {})
|
||||
},
|
||||
prefetch,
|
||||
shouldPrefetch(sessionID: string, limit: number) {
|
||||
if (data.message[sessionID] === undefined) return true
|
||||
@@ -1300,6 +1420,7 @@ export function createServerSession(
|
||||
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||
if (!items)
|
||||
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
||||
indexProjectedMessage(input.message)
|
||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
@@ -1333,6 +1454,9 @@ export function createServerSession(
|
||||
)
|
||||
return
|
||||
}
|
||||
setData("session_message", input.sessionID, (messages) =>
|
||||
messages?.filter((message) => message.id !== input.messageID),
|
||||
)
|
||||
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||
},
|
||||
|
||||
@@ -5,13 +5,22 @@ import type {
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
SessionListInput,
|
||||
OpenCodeEvent,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
|
||||
import {
|
||||
captureSessionMove,
|
||||
loadActiveSessionsQuery,
|
||||
loadMcpQuery,
|
||||
loadMcpResourcesQuery,
|
||||
reconcileActiveSessionStatuses,
|
||||
seedActiveSessionStatuses,
|
||||
} from "./server-sync"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { createServerSession } from "./server-session"
|
||||
import { adaptServerEvent } from "./server-sdk"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
|
||||
type McpApi = ServerApi["mcp"]
|
||||
@@ -99,6 +108,41 @@ describe("active session query", () => {
|
||||
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("session move normalization", () => {
|
||||
test("captures and applies current moves from the source placement", () => {
|
||||
const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"])
|
||||
session.remember(sessionAt("/source"))
|
||||
const current = {
|
||||
id: "event-current-move",
|
||||
created: 10,
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: "session", seq: 1, version: 1 },
|
||||
location: { directory: "/source" },
|
||||
data: { sessionID: "session", location: { directory: "/destination" }, projectID: "test-project" },
|
||||
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>
|
||||
const event = adaptServerEvent(current)
|
||||
|
||||
expect(captureSessionMove(event, session.get)).toEqual({
|
||||
sessionID: "session",
|
||||
from: "/source",
|
||||
})
|
||||
session.applyV2(current)
|
||||
session.apply(event)
|
||||
expect(session.get("session")?.location.directory).toBe("/destination")
|
||||
})
|
||||
})
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
@@ -171,6 +215,18 @@ function sessionInfo(id: string) {
|
||||
} as SessionInfo
|
||||
}
|
||||
|
||||
function sessionAt(directory: string): SessionInfo {
|
||||
return {
|
||||
id: "session",
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
title: "Session",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
describe("estimateRootSessionTotal", () => {
|
||||
test("keeps exact total for full fetches", () => {
|
||||
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, untrack } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { InitError } from "../pages/error"
|
||||
import { ServerSDK } from "./server-sdk"
|
||||
import { ServerSDK, type ServerEvent } from "./server-sdk"
|
||||
import {
|
||||
bootstrapDirectory,
|
||||
bootstrapGlobal,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
loadAgentsQuery,
|
||||
loadCommands,
|
||||
loadGlobalConfigQuery,
|
||||
loadIntegrationsQuery,
|
||||
loadPathQuery,
|
||||
loadProjectsQuery,
|
||||
loadProvidersQuery,
|
||||
@@ -29,7 +30,7 @@ import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from
|
||||
import type { SolidQueryOptions } from "@tanstack/solid-query"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { directoryKey } from "./global-sync/utils"
|
||||
import { PathKey } from "@/utils/path-key"
|
||||
import { pathKey, PathKey } from "@/utils/path-key"
|
||||
import { createDirSyncContext } from "./directory-sync"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
@@ -53,8 +54,33 @@ import type {
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createServerSession, type ServerSession } from "./server-session"
|
||||
import { createCatalogSync } from "./server-sync/catalog"
|
||||
import { createConnectionSync } from "./server-sync/connection"
|
||||
import { createLocationSync } from "./server-sync/location"
|
||||
import { usePlatform } from "./platform"
|
||||
|
||||
export function captureSessionMove(
|
||||
event: ServerEvent,
|
||||
get: (sessionID: string) => { location: { directory: string } } | undefined,
|
||||
) {
|
||||
if (event.current?.type !== "session.moved") return
|
||||
return {
|
||||
sessionID: event.current.data.sessionID,
|
||||
from: get(event.current.data.sessionID)?.location.directory,
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
||||
const type = event.current?.type ?? event.type
|
||||
return (
|
||||
type === "session.created" ||
|
||||
type === "session.deleted" ||
|
||||
type === "session.moved" ||
|
||||
type === "session.renamed" ||
|
||||
type === "session.forked"
|
||||
)
|
||||
}
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
error?: InitError
|
||||
@@ -159,11 +185,24 @@ export function seedActiveSessionStatuses(
|
||||
}
|
||||
}
|
||||
|
||||
export function reconcileActiveSessionStatuses(
|
||||
session: Pick<ServerSession, "data" | "set">,
|
||||
active: SessionActiveOutput,
|
||||
) {
|
||||
for (const sessionID of Object.keys(session.data.session_status)) {
|
||||
if (active[sessionID] !== undefined) continue
|
||||
if (session.data.session_status[sessionID]?.type !== "idle")
|
||||
session.set("session_status", sessionID, { type: "idle" })
|
||||
}
|
||||
for (const sessionID of Object.keys(active)) session.set("session_status", sessionID, { type: "busy" })
|
||||
}
|
||||
|
||||
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
||||
return {
|
||||
globalConfig: () => loadGlobalConfigQuery(scope),
|
||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
||||
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),
|
||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
|
||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
|
||||
@@ -187,26 +226,41 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
|
||||
const session = createServerSession(serverSDK.api.session, serverSDK.api.message)
|
||||
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 [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
||||
queries: [
|
||||
{ ...queryOptionsApi.globalConfig(), enabled: connected() },
|
||||
{ ...queryOptionsApi.providers(null), enabled: connected() },
|
||||
{ ...queryOptionsApi.path(null), enabled: connected() },
|
||||
],
|
||||
}))
|
||||
const activeSessionsQuery = useQuery(() =>
|
||||
loadActiveSessionsQuery(serverSDK.scope, {
|
||||
const activeSessionsQuery = useQuery(() => ({
|
||||
...loadActiveSessionsQuery(serverSDK.scope, {
|
||||
active: async () => {
|
||||
const active = await serverSDK.api.session.active()
|
||||
seedActiveSessionStatuses(session, active)
|
||||
reconcileActiveSessionStatuses(session, active)
|
||||
for (const sessionID of Object.keys(active)) {
|
||||
void session.resolve(sessionID).catch(() => undefined)
|
||||
void Promise.all([session.resolve(sessionID), hydrateSessionState(sessionID)]).catch(() => undefined)
|
||||
}
|
||||
return active
|
||||
},
|
||||
}),
|
||||
)
|
||||
enabled: connected(),
|
||||
}))
|
||||
|
||||
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
||||
get ready() {
|
||||
return !bootstrap.isPending
|
||||
return bootstrap.isSuccess
|
||||
},
|
||||
project: [],
|
||||
provider_auth: {},
|
||||
@@ -231,21 +285,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
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[])) => {
|
||||
setGlobalStore("project", next)
|
||||
}
|
||||
@@ -270,9 +309,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
setGlobalStore: setBootStore,
|
||||
queryClient,
|
||||
})
|
||||
bootedAt = Date.now()
|
||||
return bootedAt
|
||||
return Date.now()
|
||||
},
|
||||
enabled: connected(),
|
||||
}))
|
||||
|
||||
const set = ((...input: unknown[]) => {
|
||||
@@ -294,11 +333,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
|
||||
const children = createChildStoreManager({
|
||||
owner,
|
||||
connected,
|
||||
scope: serverSDK.scope,
|
||||
persist: persisted,
|
||||
isBooting: (directory) => booting.has(directory),
|
||||
isLoadingSessions: (directory) => sessionLoads.has(directory),
|
||||
onBootstrap: (directory) => {
|
||||
if (!connected()) return
|
||||
void bootstrapInstance(directory)
|
||||
},
|
||||
onMcp: (directory, setStore) => {
|
||||
@@ -321,9 +362,44 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
translate: language.t,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: {
|
||||
provider: globalStore.provider,
|
||||
get 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 connection = createConnectionSync({
|
||||
status: serverSDK.connection.status,
|
||||
invalidate: session.invalidate,
|
||||
connected: (info) => {
|
||||
if (info.reconnect) void session.refreshPinned()
|
||||
if (activeSessionsQuery.data !== undefined && !activeSessionsQuery.isFetching) void activeSessionsQuery.refetch()
|
||||
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
|
||||
for (const directory of Object.keys(children.children)) {
|
||||
if (children.active(directory)) queue.push(directory)
|
||||
}
|
||||
},
|
||||
})
|
||||
const location = createLocationSync({
|
||||
scope: serverSDK.scope,
|
||||
queryClient,
|
||||
active: () => Object.keys(children.children).filter(children.active).map(pathKey),
|
||||
info: (directory) => serverSDK.api.location.get({ location: { directory } }),
|
||||
vcs: (directory) => serverSDK.api.vcs.get({ location: { directory } }).then((result) => result.data),
|
||||
skill: (directory) => serverSDK.api.skill.list({ location: { directory } }).then((result) => result.data),
|
||||
websearch: (directory) =>
|
||||
serverSDK.api.websearch.providers({ location: { directory } }).then((result) => result.data),
|
||||
shell: (directory) => serverSDK.api.shell.list({ location: { directory } }).then((result) => result.data),
|
||||
})
|
||||
|
||||
async function loadSessions(directory: string, options?: { limit?: number }) {
|
||||
const key = directoryKey(directory)
|
||||
@@ -458,15 +534,49 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
})
|
||||
}
|
||||
|
||||
const reindexSession = (sessionID: string, from?: string) => {
|
||||
const next = session.get(sessionID)
|
||||
if (!next) return
|
||||
indexSession(next)
|
||||
if (!from) return
|
||||
const source = children.children[directoryKey(from)]
|
||||
if (!source) return
|
||||
applyDirectoryEvent({
|
||||
event: {
|
||||
type: "session.moved",
|
||||
properties: {
|
||||
sessionID,
|
||||
projectID: next.projectID,
|
||||
location: next.location,
|
||||
subpath: next.subpath,
|
||||
},
|
||||
},
|
||||
directory: from,
|
||||
store: source[0],
|
||||
setStore: source[1],
|
||||
push: queue.push,
|
||||
retainedLimit: sessionMeta.get(directoryKey(from))?.limit,
|
||||
sessionContent: false,
|
||||
permission: session.data.permission,
|
||||
loadLsp() {},
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const key = directoryKey(directory)
|
||||
const event = e.details
|
||||
const eventType: string = event.type
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
|
||||
const moved = captureSessionMove(event, session.get)
|
||||
if (event.current) session.applyV2(event.current)
|
||||
session.apply(event)
|
||||
if (moved) reindexSession(moved.sessionID, moved.from)
|
||||
if (shouldRefreshWorkspaceSessions(event)) {
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "settings-workspace-sessions",
|
||||
})
|
||||
}
|
||||
if (event.current?.type === "session.created")
|
||||
void session
|
||||
.resolve(event.current.data.sessionID, { force: true })
|
||||
@@ -487,42 +597,41 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
if (event.type === "session.created" || event.type === "session.deleted") {
|
||||
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 info = session.get(event.current.data.sessionID)
|
||||
if (info)
|
||||
homeSessions.apply({
|
||||
type: "session.updated",
|
||||
properties: { sessionID: info.id, info },
|
||||
})
|
||||
}
|
||||
homeSessions.refresh(event.type)
|
||||
if (eventType === "integration.connection.updated") void refreshProviders()
|
||||
catalog.main({ type: eventType, directory })
|
||||
connection.main({ type: eventType, directory })
|
||||
if (event.current) location.main(event.current, directory)
|
||||
|
||||
if (directory === "global") {
|
||||
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
|
||||
void activeSessionsQuery.refetch()
|
||||
applyGlobalEvent({
|
||||
event,
|
||||
project: globalStore.project,
|
||||
refresh: () => {
|
||||
if (recent) return
|
||||
bootstrap.refetch()
|
||||
},
|
||||
refresh: () => void bootstrap.refetch(),
|
||||
setGlobalProject: setProjects,
|
||||
})
|
||||
if (
|
||||
eventType === "config.updated" ||
|
||||
eventType === "catalog.updated" ||
|
||||
eventType === "agent.updated" ||
|
||||
eventType === "project.directories.updated"
|
||||
)
|
||||
bootstrap.refetch()
|
||||
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)
|
||||
}
|
||||
}
|
||||
if (eventType === "global.disposed")
|
||||
for (const directory of Object.keys(children.children)) if (children.active(directory)) queue.push(directory)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.current?.type === "session.moved") {
|
||||
const info = session.get(event.current.data.sessionID)
|
||||
if (info) indexSession(info)
|
||||
}
|
||||
if (event.current?.type === "session.forked")
|
||||
void session
|
||||
.resolve(event.current.data.sessionID, { force: true })
|
||||
@@ -536,7 +645,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
event.current?.type === "session.moved" ||
|
||||
// event.current?.type === "session.archived" ||
|
||||
event.current?.type === "session.forked" ||
|
||||
eventType === "command.updated" ||
|
||||
eventType === "config.updated" ||
|
||||
eventType === "agent.updated"
|
||||
)
|
||||
@@ -544,6 +652,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key))
|
||||
if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key))
|
||||
const [store, setStore] = existing
|
||||
if (eventType === "agent.updated")
|
||||
void queryClient
|
||||
.fetchQuery(queryOptionsApi.agents(key))
|
||||
.then((data) => setStore("agent", data))
|
||||
.catch(() => {})
|
||||
if (eventType === "command.updated")
|
||||
void loadCommands(directory, serverSDK.api.command)
|
||||
.then((commands) => setStore("command", commands))
|
||||
.catch(() => {})
|
||||
if (eventType === "project.directories.updated") void bootstrap.refetch()
|
||||
applyDirectoryEvent({
|
||||
event,
|
||||
directory,
|
||||
@@ -577,23 +695,6 @@ 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 = {
|
||||
loadSessions,
|
||||
meta(directory: string, patch: ProjectMeta) {
|
||||
@@ -634,11 +735,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
peek: children.peek,
|
||||
disableMcp: children.disableMcp,
|
||||
queryOptions: queryOptionsApi,
|
||||
refreshProviders,
|
||||
refreshProviders: catalog.refreshActive,
|
||||
// bootstrap,
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
project: projectApi,
|
||||
session,
|
||||
reindexSession,
|
||||
homeSessions,
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
@@ -699,13 +801,13 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
|
||||
name: "ServerSync",
|
||||
// Returns an accessor so the resolved server can change reactively without
|
||||
// re-instantiating the subtree (mirrors useServerSDK).
|
||||
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
|
||||
init: (props: { server?: ServerConnection.Any }) => {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
|
||||
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"))
|
||||
return global.ensureServerCtx(conn).sync
|
||||
})
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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 two = [ServerScope.local, "/two", "providers"] as const
|
||||
queryClient.setQueryData(one, { providers: ["one"] })
|
||||
queryClient.setQueryData(two, { providers: ["two"] })
|
||||
const catalog = createCatalogSync({
|
||||
scope: ServerScope.local,
|
||||
queryClient,
|
||||
active: () => [pathKey("/one"), pathKey("/two")],
|
||||
load: async () => {},
|
||||
})
|
||||
|
||||
catalog.main({ type: "catalog.updated", directory: "/one" })
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(queryClient.getQueryState(one)?.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.main({ 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)
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
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 main(event: CatalogEvent) {
|
||||
if (event.type === "server.connected") {
|
||||
void refreshActive()
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "catalog.updated" ||
|
||||
event.type === "integration.updated" ||
|
||||
event.type === "integration.connection.updated"
|
||||
) {
|
||||
void refresh(event.directory === "global" ? null : pathKey(event.directory))
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(directory: PathKey | null) {
|
||||
await input.queryClient.invalidateQueries({
|
||||
queryKey: [input.scope, directory, "providers"],
|
||||
exact: true,
|
||||
refetchType: "none",
|
||||
})
|
||||
await input.load(directory)
|
||||
}
|
||||
|
||||
function refreshActive() {
|
||||
return Promise.all([null, ...new Set(input.active())].map(refresh)).then(() => undefined)
|
||||
}
|
||||
|
||||
return {
|
||||
main,
|
||||
refresh,
|
||||
refreshActive,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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.main({ type: "server.connected", directory: "global" })
|
||||
expect(calls).toContain("connected")
|
||||
connection.main({ type: "server.connected", directory: "/repo" })
|
||||
expect(calls.filter((call) => call === "connected")).toHaveLength(1)
|
||||
setStatus("connected")
|
||||
return dispose
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
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 main(event: { type: string; directory: string }) {
|
||||
if (event.directory !== "global" || event.type !== "server.connected") return
|
||||
input.connected({ reconnect: connectedOnce })
|
||||
connectedOnce = true
|
||||
}
|
||||
|
||||
return { main }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { createLocationSync } from "./location"
|
||||
|
||||
test("projects shell events and refreshes location catalogs", async () => {
|
||||
const queryClient = new QueryClient()
|
||||
const directory = pathKey("/repo")
|
||||
const calls: string[] = []
|
||||
const location = createLocationSync({
|
||||
scope: ServerScope.local,
|
||||
queryClient,
|
||||
active: () => [directory],
|
||||
info: async () => calls.push("info"),
|
||||
vcs: async () => calls.push("vcs"),
|
||||
skill: async () => calls.push("skill"),
|
||||
websearch: async () => calls.push("websearch"),
|
||||
shell: async () => {
|
||||
calls.push("shell")
|
||||
return []
|
||||
},
|
||||
})
|
||||
|
||||
location.main(
|
||||
{
|
||||
type: "shell.created",
|
||||
id: "evt_1",
|
||||
data: {
|
||||
info: {
|
||||
id: "sh_1",
|
||||
status: "running",
|
||||
command: "echo hi",
|
||||
cwd: "/repo",
|
||||
shell: "bash",
|
||||
file: "",
|
||||
metadata: {},
|
||||
time: { started: 1 },
|
||||
},
|
||||
},
|
||||
} as OpenCodeEvent,
|
||||
directory,
|
||||
)
|
||||
location.main({ type: "skill.updated", id: "evt_2", data: {} } as OpenCodeEvent, directory)
|
||||
location.main({ type: "websearch.updated", id: "evt_3", data: {} } as OpenCodeEvent, directory)
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(queryClient.getQueryData<unknown[]>([ServerScope.local, directory, "shell"])).toHaveLength(1)
|
||||
expect(calls).toEqual(["skill", "websearch"])
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { OpenCodeEvent, ShellInfo } from "@opencode-ai/client/promise"
|
||||
import type { QueryClient } from "@tanstack/solid-query"
|
||||
import { pathKey, type PathKey } from "@/utils/path-key"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
export function createLocationSync(input: {
|
||||
scope: ServerScope
|
||||
queryClient: QueryClient
|
||||
active: () => PathKey[]
|
||||
info: (directory: PathKey) => Promise<unknown>
|
||||
vcs: (directory: PathKey) => Promise<unknown>
|
||||
skill: (directory: PathKey) => Promise<unknown>
|
||||
websearch: (directory: PathKey) => Promise<unknown>
|
||||
shell: (directory: PathKey) => Promise<ShellInfo[]>
|
||||
}) {
|
||||
function main(event: OpenCodeEvent, directory: string) {
|
||||
if (event.type === "server.connected") {
|
||||
void refreshActive()
|
||||
return
|
||||
}
|
||||
if (directory === "global") return
|
||||
const key = pathKey(directory)
|
||||
|
||||
if (event.type === "skill.updated") void refreshSkill(key)
|
||||
if (event.type === "config.updated" || event.type === "websearch.updated") void refreshWebsearch(key)
|
||||
if (event.type === "shell.created") rememberShell(key, event.data.info)
|
||||
if (event.type === "shell.exited" || event.type === "shell.deleted") forgetShell(key, event.data.id)
|
||||
}
|
||||
|
||||
function refreshActive() {
|
||||
return Promise.all(
|
||||
input
|
||||
.active()
|
||||
.flatMap((directory) => [
|
||||
refreshInfo(directory),
|
||||
refreshVcs(directory),
|
||||
refreshSkill(directory),
|
||||
refreshWebsearch(directory),
|
||||
refreshShell(directory),
|
||||
]),
|
||||
).then(() => undefined)
|
||||
}
|
||||
|
||||
async function refreshInfo(directory: PathKey) {
|
||||
input.queryClient.setQueryData(infoKey(directory), await input.info(directory))
|
||||
}
|
||||
|
||||
async function refreshVcs(directory: PathKey) {
|
||||
input.queryClient.setQueryData(vcsKey(directory), await input.vcs(directory))
|
||||
}
|
||||
|
||||
async function refreshSkill(directory: PathKey) {
|
||||
input.queryClient.setQueryData(skillKey(directory), await input.skill(directory))
|
||||
}
|
||||
|
||||
async function refreshWebsearch(directory: PathKey) {
|
||||
input.queryClient.setQueryData(websearchKey(directory), await input.websearch(directory))
|
||||
}
|
||||
|
||||
async function refreshShell(directory: PathKey) {
|
||||
input.queryClient.setQueryData(shellKey(directory), await input.shell(directory))
|
||||
}
|
||||
|
||||
function rememberShell(directory: PathKey, shell: ShellInfo) {
|
||||
input.queryClient.setQueryData<ShellInfo[]>(shellKey(directory), (current = []) => [
|
||||
...current.filter((item) => item.id !== shell.id),
|
||||
shell,
|
||||
])
|
||||
}
|
||||
|
||||
function forgetShell(directory: PathKey, shellID: string) {
|
||||
input.queryClient.setQueryData<ShellInfo[]>(shellKey(directory), (current = []) =>
|
||||
current.filter((item) => item.id !== shellID),
|
||||
)
|
||||
}
|
||||
|
||||
const skillKey = (directory: PathKey) => [input.scope, directory, "skills"] as const
|
||||
const infoKey = (directory: PathKey) => [input.scope, directory, "path"] as const
|
||||
const vcsKey = (directory: PathKey) => [input.scope, directory, "vcs"] as const
|
||||
const websearchKey = (directory: PathKey) => [input.scope, directory, "websearch"] as const
|
||||
const shellKey = (directory: PathKey) => [input.scope, directory, "shell"] as const
|
||||
|
||||
return { main }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user