mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-14 15:32:52 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32e2e3f2f7 |
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"@opencode-ai/core": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
|
|
||||||
@@ -10,7 +10,6 @@ kitlangton
|
|||||||
kommander
|
kommander
|
||||||
ludvigrask
|
ludvigrask
|
||||||
MrMushrooooom
|
MrMushrooooom
|
||||||
neriousy
|
|
||||||
nexxeln
|
nexxeln
|
||||||
R44VC0RP
|
R44VC0RP
|
||||||
rekram1-node
|
rekram1-node
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
name: deploy-lab-catalog
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [v2]
|
|
||||||
paths:
|
|
||||||
- ".github/workflows/deploy-lab-catalog.yml"
|
|
||||||
- "bun.lock"
|
|
||||||
- "package.json"
|
|
||||||
- "packages/drive/**"
|
|
||||||
- "packages/protocol/src/simulation.ts"
|
|
||||||
- "packages/simulation/**"
|
|
||||||
- "packages/lab/catalog/**"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-lab-catalog-${{ github.ref_name }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
|
||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
|
||||||
|
|
||||||
- name: Install ffmpeg
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install --yes ffmpeg
|
|
||||||
|
|
||||||
- name: Validate
|
|
||||||
run: |
|
|
||||||
bun --cwd packages/protocol typecheck
|
|
||||||
bun --cwd packages/simulation typecheck
|
|
||||||
bun --cwd packages/drive run check
|
|
||||||
bun --cwd packages/drive run test
|
|
||||||
bun --cwd packages/lab/catalog run check
|
|
||||||
|
|
||||||
- name: Deploy
|
|
||||||
working-directory: packages/lab/catalog
|
|
||||||
run: bun run deploy
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
@@ -6,6 +6,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- ci
|
- ci
|
||||||
- dev
|
- dev
|
||||||
|
- v2
|
||||||
- beta
|
- beta
|
||||||
- fix/npm-native-binary-install
|
- fix/npm-native-binary-install
|
||||||
- snapshot-*
|
- snapshot-*
|
||||||
|
|||||||
@@ -55,12 +55,6 @@ jobs:
|
|||||||
git config --global user.email "bot@opencode.ai"
|
git config --global user.email "bot@opencode.ai"
|
||||||
git config --global user.name "opencode"
|
git config --global user.name "opencode"
|
||||||
|
|
||||||
- name: Install ffmpeg
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install --yes ffmpeg
|
|
||||||
|
|
||||||
- name: Cache Turbo
|
- name: Cache Turbo
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
with:
|
with:
|
||||||
@@ -72,7 +66,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }}
|
run: GITHUB_ACTIONS=false bun turbo test
|
||||||
env:
|
env:
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ files. If the script is unsuccessful, automatically fix the script and run it ag
|
|||||||
Scripts use one typed definition object. `setup` runs before OpenCode starts,
|
Scripts use one typed definition object. `setup` runs before OpenCode starts,
|
||||||
and `fs.writeFile` always writes inside the simulated project.
|
and `fs.writeFile` always writes inside the simulated project.
|
||||||
|
|
||||||
You can read the full typed API here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/src/script/types.ts
|
You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { defineScript } from "opencode-drive"
|
import { defineScript } from "opencode-drive"
|
||||||
@@ -83,7 +83,7 @@ itself (this is extremely rare, do not use this unless explicitly asked). In thi
|
|||||||
mode `ui` is typed as `null`; call `server.launch()` exactly
|
mode `ui` is typed as `null`; call `server.launch()` exactly
|
||||||
once before launching clients. Each `clients.launch(name)` result provides the
|
once before launching clients. Each `clients.launch(name)` result provides the
|
||||||
same UI methods as the automatic client. You can see an example of this API
|
same UI methods as the automatic client. You can see an example of this API
|
||||||
here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/multiple-clients.ts
|
here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
|
||||||
|
|
||||||
Use the exported `wait(milliseconds)` utility for an unconditional delay.
|
Use the exported `wait(milliseconds)` utility for an unconditional delay.
|
||||||
|
|
||||||
@@ -114,8 +114,8 @@ completion are automatic.
|
|||||||
|
|
||||||
You can see some example scripts here:
|
You can see some example scripts here:
|
||||||
|
|
||||||
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/simple.ts
|
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
|
||||||
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/serve.ts
|
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
|
||||||
|
|
||||||
## Prune
|
## Prune
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
|
|||||||
const TEAM = {
|
const TEAM = {
|
||||||
tui: ["kommander", "simonklee"],
|
tui: ["kommander", "simonklee"],
|
||||||
desktop_web: ["Hona", "Brendonovich"],
|
desktop_web: ["Hona", "Brendonovich"],
|
||||||
core: ["jlongster", "rekram1-node", "neriousy", "nexxeln", "kitlangton"],
|
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
|
||||||
inference: ["fwang", "MrMushrooooom", "starptech"],
|
inference: ["fwang", "MrMushrooooom", "starptech"],
|
||||||
windows: ["Hona"],
|
windows: ["Hona"],
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||||
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||||
- The default branch in this repo is `v2`.
|
- The default branch in this repo is `dev`.
|
||||||
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
|
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||||
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
|
|
||||||
|
|
||||||
## Live V2 TUI Testing
|
## Live V2 TUI Testing
|
||||||
|
|
||||||
@@ -171,4 +170,4 @@ const table = sqliteTable("session", {
|
|||||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||||
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||||
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Unavailable sources retain the last value and block only the initial complete delta.
|
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement and committed revert clear it. Unavailable sources retain the last value and block only the initial complete delta.
|
||||||
|
|||||||
+7
-10
@@ -33,7 +33,6 @@
|
|||||||
"packages": [
|
"packages": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
"packages/console/*",
|
"packages/console/*",
|
||||||
"packages/lab/*",
|
|
||||||
"packages/stats/*",
|
"packages/stats/*",
|
||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
@@ -47,9 +46,9 @@
|
|||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/standard-validator": "0.2.0",
|
"@hono/standard-validator": "0.2.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.5.2",
|
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||||
"@opentui/keymap": "0.5.2",
|
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||||
"@opentui/solid": "0.5.2",
|
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||||
"@tanstack/solid-virtual": "3.13.32",
|
"@tanstack/solid-virtual": "3.13.32",
|
||||||
"@shikijs/stream": "4.2.0",
|
"@shikijs/stream": "4.2.0",
|
||||||
"@standard-schema/spec": "1.1.0",
|
"@standard-schema/spec": "1.1.0",
|
||||||
@@ -121,8 +120,7 @@
|
|||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"semver": "^7.6.0",
|
"semver": "^7.6.0",
|
||||||
"sst": "catalog:",
|
"sst": "catalog:",
|
||||||
"turbo": "2.10.2",
|
"turbo": "2.10.2"
|
||||||
"vitest": "4.1.10"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.933.0",
|
"@aws-sdk/client-s3": "3.933.0",
|
||||||
@@ -152,9 +150,9 @@
|
|||||||
"electron"
|
"electron"
|
||||||
],
|
],
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||||
"@opentui/keymap": "catalog:",
|
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"effect": "catalog:"
|
"effect": "catalog:"
|
||||||
@@ -174,7 +172,6 @@
|
|||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
||||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||||
"@cloudflare/vitest-pool-workers@0.12.6": "patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch",
|
|
||||||
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
|
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -573,10 +573,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
|||||||
cache_control: cacheControl(breakpoints, part.cache),
|
cache_control: cacheControl(breakpoints, part.cache),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const previous = messages.at(-1)
|
messages.push({ role: "user", content })
|
||||||
if (previous?.role === "user" && previous.content.every((block) => block.type === "tool_result"))
|
|
||||||
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
|
|
||||||
else messages.push({ role: "user", content })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Cause, Context, Effect, Layer, Option, Schema, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
|
||||||
import {
|
import {
|
||||||
FetchHttpClient,
|
FetchHttpClient,
|
||||||
Headers,
|
Headers,
|
||||||
@@ -297,86 +297,44 @@ export const classifyHttpFailure = (input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type HttpOperation = "request" | "read"
|
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
|
||||||
|
const transportError = (input: {
|
||||||
const NativeTransportFailure = Schema.Struct({
|
readonly message: string
|
||||||
message: Schema.String,
|
readonly kind?: string | undefined
|
||||||
code: Schema.optionalKey(Schema.String),
|
readonly request?: HttpClientRequest.HttpClientRequest | undefined
|
||||||
cause: Schema.optionalKey(Schema.Unknown),
|
}) =>
|
||||||
})
|
|
||||||
const decodeNativeTransportFailure = Schema.decodeUnknownOption(NativeTransportFailure)
|
|
||||||
|
|
||||||
const nativeTransportFailure = (error: unknown) => {
|
|
||||||
const failure = Option.getOrUndefined(decodeNativeTransportFailure(error))
|
|
||||||
if (!failure) return undefined
|
|
||||||
if (failure.code !== undefined) return failure
|
|
||||||
const cause = Option.getOrUndefined(decodeNativeTransportFailure(failure.cause))
|
|
||||||
if (cause?.code !== undefined) return cause
|
|
||||||
return failure
|
|
||||||
}
|
|
||||||
|
|
||||||
const httpError = (input: {
|
|
||||||
readonly error: unknown
|
|
||||||
readonly request: HttpClientRequest.HttpClientRequest
|
|
||||||
readonly operation: HttpOperation
|
|
||||||
readonly redactedNames: ReadonlyArray<string | RegExp>
|
|
||||||
}) => {
|
|
||||||
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
|
|
||||||
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
|
|
||||||
new AIError({
|
new AIError({
|
||||||
module: "RequestExecutor",
|
module: "RequestExecutor",
|
||||||
method: input.operation,
|
method: "execute",
|
||||||
reason: new TransportReason({
|
reason: new TransportReason({
|
||||||
message: failure.message,
|
message: input.message,
|
||||||
transport: "http",
|
kind: input.kind,
|
||||||
operation: input.operation,
|
url: input.request ? redactUrl(input.request.url) : undefined,
|
||||||
code: failure.code,
|
http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
|
||||||
url: redactUrl(request.url),
|
|
||||||
http: new HttpContext({ request: requestDetails(request, input.redactedNames) }),
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const source =
|
if (Cause.isTimeoutError(error)) {
|
||||||
HttpClientError.isHttpClientError(input.error) && "cause" in input.error.reason
|
return transportError({ message: error.message, kind: "Timeout" })
|
||||||
? input.error.reason.cause
|
}
|
||||||
: input.error
|
if (!HttpClientError.isHttpClientError(error)) {
|
||||||
const native = nativeTransportFailure(source)
|
return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" })
|
||||||
const code = native?.code
|
}
|
||||||
const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined)
|
const request = "request" in error ? error.request : undefined
|
||||||
const detail = raw ? redactBody(raw, secretValues(request)) : undefined
|
if (error.reason._tag === "TransportError") {
|
||||||
const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail
|
|
||||||
|
|
||||||
if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source))
|
|
||||||
return transportError({ message: message ?? "HTTP transport timed out", code: code ?? "Timeout" })
|
|
||||||
if (!HttpClientError.isHttpClientError(input.error))
|
|
||||||
return transportError({ message: message ?? "HTTP transport failed", code })
|
|
||||||
if (input.error.reason._tag === "TransportError") {
|
|
||||||
return transportError({
|
return transportError({
|
||||||
message: message ?? input.error.reason.description ?? "HTTP transport failed",
|
message: error.reason.description ?? "HTTP transport failed",
|
||||||
code: code ?? input.error.reason._tag,
|
kind: error.reason._tag,
|
||||||
|
request,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return transportError({
|
return transportError({
|
||||||
message: message ?? `HTTP transport failed: ${input.error.reason._tag}`,
|
message: `HTTP transport failed: ${error.reason._tag}`,
|
||||||
code: code ?? input.error.reason._tag,
|
kind: error.reason._tag,
|
||||||
|
request,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const stream = (
|
|
||||||
executor: Interface,
|
|
||||||
request: HttpClientRequest.HttpClientRequest,
|
|
||||||
middleware?: HttpMiddleware,
|
|
||||||
): Stream.Stream<Uint8Array, AIError> =>
|
|
||||||
Stream.unwrap(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
|
||||||
const response = yield* executor.execute(request, middleware)
|
|
||||||
return response.stream.pipe(
|
|
||||||
Stream.mapError((error) => httpError({ error, request: response.request, operation: "read", redactedNames })),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.effect(
|
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -385,16 +343,15 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
const redactedNames = yield* Headers.CurrentRedactedNames
|
||||||
if (!middleware)
|
if (!middleware)
|
||||||
return yield* http.execute(request).pipe(
|
return yield* http
|
||||||
Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })),
|
.execute(request)
|
||||||
Effect.flatMap(statusError(request, redactedNames)),
|
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
||||||
)
|
|
||||||
|
|
||||||
const response = yield* middleware(request, (input) =>
|
const response = yield* middleware(request, (input) =>
|
||||||
http
|
http
|
||||||
.execute(input)
|
.execute(input)
|
||||||
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||||
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })))
|
).pipe(Effect.mapError(toHttpError(redactedNames)))
|
||||||
return yield* statusError(response.request, redactedNames)(response)
|
return yield* statusError(response.request, redactedNames)(response)
|
||||||
})
|
})
|
||||||
return Service.of({
|
return Service.of({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { Auth } from "../auth.js"
|
import { Auth } from "../auth.js"
|
||||||
import { render as renderEndpoint } from "../endpoint.js"
|
import { render as renderEndpoint } from "../endpoint.js"
|
||||||
@@ -6,7 +6,6 @@ import { Framing } from "../framing.js"
|
|||||||
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index.js"
|
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index.js"
|
||||||
import * as ProviderShared from "../../protocols/shared.js"
|
import * as ProviderShared from "../../protocols/shared.js"
|
||||||
import { mergeJsonRecords, type LLMRequest } from "../../schema/index.js"
|
import { mergeJsonRecords, type LLMRequest } from "../../schema/index.js"
|
||||||
import { RequestExecutor } from "../executor.js"
|
|
||||||
|
|
||||||
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
|
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
|
||||||
|
|
||||||
@@ -87,8 +86,26 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
|||||||
middleware: prepareInput.middleware,
|
middleware: prepareInput.middleware,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
frames: (prepared, _request, runtime) =>
|
frames: (prepared, request, runtime) =>
|
||||||
prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
|
Stream.unwrap(
|
||||||
|
runtime.http
|
||||||
|
.execute(prepared.request, prepared.middleware)
|
||||||
|
.pipe(
|
||||||
|
Effect.map((response) =>
|
||||||
|
prepared.framing.frame(
|
||||||
|
response.stream.pipe(
|
||||||
|
Stream.mapError((error) =>
|
||||||
|
ProviderShared.eventError(
|
||||||
|
`${request.model.provider}/${request.model.route.id}`,
|
||||||
|
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
|
||||||
|
ProviderShared.errorText(error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const sseJson = {
|
export const sseJson = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
||||||
import { Headers } from "effect/unstable/http"
|
import { Headers } from "effect/unstable/http"
|
||||||
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
|
import { AIError, TransportReason } from "../../schema/index.js"
|
||||||
import * as HttpTransport from "./http.js"
|
import * as HttpTransport from "./http.js"
|
||||||
import type { Transport } from "./index.js"
|
import type { Transport } from "./index.js"
|
||||||
|
|
||||||
@@ -29,18 +29,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AI
|
|||||||
const transportError = (
|
const transportError = (
|
||||||
method: string,
|
method: string,
|
||||||
message: string,
|
message: string,
|
||||||
input: { readonly operation: TransportOperation; readonly url?: string; readonly code?: string },
|
input: { readonly url?: string; readonly kind?: string } = {},
|
||||||
) =>
|
) =>
|
||||||
new AIError({
|
new AIError({
|
||||||
module: "WebSocketExecutor",
|
module: "WebSocketExecutor",
|
||||||
method,
|
method,
|
||||||
reason: new TransportReason({
|
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
|
||||||
message,
|
|
||||||
transport: "websocket",
|
|
||||||
operation: input.operation,
|
|
||||||
url: input.url,
|
|
||||||
code: input.code,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const eventMessage = (event: Event) => {
|
const eventMessage = (event: Event) => {
|
||||||
@@ -61,8 +55,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
|||||||
return Effect.fail(
|
return Effect.fail(
|
||||||
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
|
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "request",
|
kind: "open",
|
||||||
code: "closed",
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -86,10 +79,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
|||||||
cleanup()
|
cleanup()
|
||||||
resume(
|
resume(
|
||||||
Effect.fail(
|
Effect.fail(
|
||||||
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
|
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { url: input.url, kind: "open" }),
|
||||||
url: input.url,
|
|
||||||
operation: "request",
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -99,8 +89,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
|||||||
Effect.fail(
|
Effect.fail(
|
||||||
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
|
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "request",
|
kind: "open",
|
||||||
code: String(event.code),
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -129,8 +118,7 @@ const webSocketUrl = (value: string) =>
|
|||||||
catch: (error) =>
|
catch: (error) =>
|
||||||
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
|
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
|
||||||
url: value,
|
url: value,
|
||||||
operation: "request",
|
kind: "websocket",
|
||||||
code: "invalid-url",
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -141,7 +129,7 @@ export const open = (input: WebSocketRequest) =>
|
|||||||
catch: (error) =>
|
catch: (error) =>
|
||||||
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "request",
|
kind: "open",
|
||||||
}),
|
}),
|
||||||
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
|
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
|
||||||
|
|
||||||
@@ -162,10 +150,7 @@ export const fromWebSocket = (
|
|||||||
Queue.failCauseUnsafe(
|
Queue.failCauseUnsafe(
|
||||||
messages,
|
messages,
|
||||||
Cause.fail(
|
Cause.fail(
|
||||||
transportError("message", "Unsupported WebSocket message payload", {
|
transportError("message", "Unsupported WebSocket message payload", { url: input.url, kind: "message" }),
|
||||||
url: input.url,
|
|
||||||
operation: "read",
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -173,10 +158,7 @@ export const fromWebSocket = (
|
|||||||
Queue.failCauseUnsafe(
|
Queue.failCauseUnsafe(
|
||||||
messages,
|
messages,
|
||||||
Cause.fail(
|
Cause.fail(
|
||||||
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
|
transportError("message", `WebSocket error: ${eventMessage(event)}`, { url: input.url, kind: "message" }),
|
||||||
url: input.url,
|
|
||||||
operation: "read",
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -185,11 +167,7 @@ export const fromWebSocket = (
|
|||||||
Queue.failCauseUnsafe(
|
Queue.failCauseUnsafe(
|
||||||
messages,
|
messages,
|
||||||
Cause.fail(
|
Cause.fail(
|
||||||
transportError("message", `WebSocket closed with code ${event.code}`, {
|
transportError("message", `WebSocket closed with code ${event.code}`, { url: input.url, kind: "close" }),
|
||||||
url: input.url,
|
|
||||||
operation: "read",
|
|
||||||
code: String(event.code),
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -210,7 +188,7 @@ export const fromWebSocket = (
|
|||||||
catch: (error) =>
|
catch: (error) =>
|
||||||
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "write",
|
kind: "write",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
messages: Stream.fromQueue(messages),
|
messages: Stream.fromQueue(messages),
|
||||||
@@ -265,8 +243,7 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
|||||||
return Stream.fail(
|
return Stream.fail(
|
||||||
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
|
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
|
||||||
url: prepared.url,
|
url: prepared.url,
|
||||||
operation: "request",
|
kind: "websocket",
|
||||||
code: "unavailable",
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,18 +92,10 @@ export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>
|
|||||||
http: Schema.optional(HttpContext),
|
http: Schema.optional(HttpContext),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const TransportType = Schema.Literals(["http", "websocket"])
|
|
||||||
export type TransportType = typeof TransportType.Type
|
|
||||||
|
|
||||||
export const TransportOperation = Schema.Literals(["request", "read", "write"])
|
|
||||||
export type TransportOperation = typeof TransportOperation.Type
|
|
||||||
|
|
||||||
export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
|
export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
|
||||||
_tag: Schema.tag("Transport"),
|
_tag: Schema.tag("Transport"),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
transport: TransportType,
|
kind: Schema.optional(Schema.String),
|
||||||
operation: TransportOperation,
|
|
||||||
code: Schema.optional(Schema.String),
|
|
||||||
url: Schema.optional(Schema.String),
|
url: Schema.optional(Schema.String),
|
||||||
http: Schema.optional(HttpContext),
|
http: Schema.optional(HttpContext),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer, Ref, Stream } from "effect"
|
import { Effect, Layer, Ref } from "effect"
|
||||||
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { LLM, AIError } from "../src/index.js"
|
import { LLM, AIError } from "../src/index.js"
|
||||||
import { LLMClient, RequestExecutor } from "../src/route.js"
|
import { LLMClient, RequestExecutor } from "../src/route.js"
|
||||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||||
import { dynamicResponse, systemError } from "./lib/http.js"
|
import { dynamicResponse } from "./lib/http.js"
|
||||||
import { deltaChunk } from "./lib/openai-chunks.js"
|
import { deltaChunk } from "./lib/openai-chunks.js"
|
||||||
import { sseRaw } from "./lib/sse.js"
|
import { sseRaw } from "./lib/sse.js"
|
||||||
import { it } from "./lib/effect.js"
|
import { it } from "./lib/effect.js"
|
||||||
@@ -67,62 +67,6 @@ const expectAIError = (error: unknown) => {
|
|||||||
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
|
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
|
||||||
|
|
||||||
describe("RequestExecutor", () => {
|
describe("RequestExecutor", () => {
|
||||||
it.effect("parses response body failures at the executor seam", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const executor = yield* RequestExecutor.Service
|
|
||||||
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
|
|
||||||
|
|
||||||
expectAIError(error)
|
|
||||||
expect(error.reason).toMatchObject({
|
|
||||||
_tag: "Transport",
|
|
||||||
message: "ECONNRESET: disconnected <redacted> <redacted>",
|
|
||||||
transport: "http",
|
|
||||||
operation: "read",
|
|
||||||
code: "ECONNRESET",
|
|
||||||
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&debug=1",
|
|
||||||
})
|
|
||||||
}).pipe(
|
|
||||||
Effect.provide(
|
|
||||||
responsesLayer([
|
|
||||||
new Response(
|
|
||||||
new ReadableStream({
|
|
||||||
start(controller) {
|
|
||||||
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("unwraps native transport failure causes", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const executor = yield* RequestExecutor.Service
|
|
||||||
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
|
|
||||||
|
|
||||||
expectAIError(error)
|
|
||||||
expect(error.reason).toMatchObject({
|
|
||||||
_tag: "Transport",
|
|
||||||
message: "ECONNRESET: socket closed",
|
|
||||||
operation: "read",
|
|
||||||
code: "ECONNRESET",
|
|
||||||
})
|
|
||||||
}).pipe(
|
|
||||||
Effect.provide(
|
|
||||||
responsesLayer([
|
|
||||||
new Response(
|
|
||||||
new ReadableStream({
|
|
||||||
pull(controller) {
|
|
||||||
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("preserves middleware error messages", () =>
|
it.effect("preserves middleware error messages", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const executor = yield* RequestExecutor.Service
|
const executor = yield* RequestExecutor.Service
|
||||||
@@ -135,48 +79,6 @@ describe("RequestExecutor", () => {
|
|||||||
}).pipe(Effect.provide(responsesLayer([]))),
|
}).pipe(Effect.provide(responsesLayer([]))),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("reports the request sent by middleware", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const executor = yield* RequestExecutor.Service
|
|
||||||
const error = yield* executor
|
|
||||||
.execute(request, (original, handler) =>
|
|
||||||
handler(
|
|
||||||
original.pipe(
|
|
||||||
HttpClientRequest.setUrl("https://proxy.test/v1/chat?api_key=proxy-secret"),
|
|
||||||
HttpClientRequest.setHeader("authorization", "Bearer proxy-secret"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.pipe(Effect.flip)
|
|
||||||
|
|
||||||
expectAIError(error)
|
|
||||||
expect(error.reason).toMatchObject({
|
|
||||||
_tag: "Transport",
|
|
||||||
message: "ECONNRESET: proxy disconnected <redacted>",
|
|
||||||
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
|
|
||||||
http: {
|
|
||||||
request: {
|
|
||||||
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
|
|
||||||
headers: { authorization: "<redacted>" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}).pipe(
|
|
||||||
Effect.provide(
|
|
||||||
dynamicResponse((input) =>
|
|
||||||
Effect.fail(
|
|
||||||
new HttpClientError.HttpClientError({
|
|
||||||
reason: new HttpClientError.TransportError({
|
|
||||||
request: input.request,
|
|
||||||
cause: systemError("ECONNRESET", "proxy disconnected proxy-secret"),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("classifies context overflow responses", () =>
|
it.effect("classifies context overflow responses", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const executor = yield* RequestExecutor.Service
|
const executor = yield* RequestExecutor.Service
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Effect, Layer, Ref } from "effect"
|
import { Effect, Layer, Ref } from "effect"
|
||||||
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js"
|
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js"
|
||||||
import type { Service as LLMClientService } from "../../src/route/client.js"
|
import type { Service as LLMClientService } from "../../src/route/client.js"
|
||||||
import type { Service as RequestExecutorService } from "../../src/route/executor.js"
|
import type { Service as RequestExecutorService } from "../../src/route/executor.js"
|
||||||
@@ -14,9 +14,7 @@ export type HandlerInput = {
|
|||||||
) => HttpClientResponse.HttpClientResponse
|
) => HttpClientResponse.HttpClientResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Handler = (
|
export type Handler = (input: HandlerInput) => Effect.Effect<HttpClientResponse.HttpClientResponse>
|
||||||
input: HandlerInput,
|
|
||||||
) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>
|
|
||||||
|
|
||||||
const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
|
const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
|
||||||
Layer.succeed(
|
Layer.succeed(
|
||||||
@@ -36,12 +34,6 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
|
|||||||
|
|
||||||
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
|
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
|
||||||
|
|
||||||
export interface SystemError extends Error {
|
|
||||||
readonly code: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const systemError = (code: string, message: string): SystemError => Object.assign(new Error(message), { code })
|
|
||||||
|
|
||||||
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
|
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
|
||||||
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
|
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
|
||||||
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
||||||
@@ -71,20 +63,14 @@ export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(h
|
|||||||
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
|
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
|
||||||
* exercise transport errors that surface during parsing.
|
* exercise transport errors that surface during parsing.
|
||||||
*/
|
*/
|
||||||
export const truncatedStream = (chunks: ReadonlyArray<string>, error: Error = new Error("connection reset")) =>
|
export const truncatedStream = (chunks: ReadonlyArray<string>) =>
|
||||||
dynamicResponse((input) =>
|
dynamicResponse((input) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
let index = 0
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
pull(controller) {
|
start(controller) {
|
||||||
const chunk = chunks[index]
|
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
|
||||||
if (chunk !== undefined) {
|
controller.error(new Error("connection reset"))
|
||||||
index++
|
|
||||||
controller.enqueue(encoder.encode(chunk))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
controller.error(error)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return input.respond(stream, { headers: SSE_HEADERS })
|
return input.respond(stream, { headers: SSE_HEADERS })
|
||||||
|
|||||||
@@ -271,47 +271,6 @@ describe("Anthropic Messages route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("batches parallel tool results into one Anthropic user message", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const prepared = yield* compileRequest(
|
|
||||||
LLM.request({
|
|
||||||
model,
|
|
||||||
messages: [
|
|
||||||
Message.user("Check both cities."),
|
|
||||||
Message.assistant([
|
|
||||||
{ type: "text", text: "I'll check both." },
|
|
||||||
ToolCallPart.make({ id: "call_paris", name: "weather", input: { city: "Paris" } }),
|
|
||||||
ToolCallPart.make({ id: "call_london", name: "weather", input: { city: "London" } }),
|
|
||||||
]),
|
|
||||||
Message.tool({ id: "call_paris", name: "weather", result: { temperature: 22 } }),
|
|
||||||
Message.tool({ id: "call_london", name: "weather", result: { temperature: 18 } }),
|
|
||||||
],
|
|
||||||
cache: "none",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(prepared.body.messages).toMatchObject([
|
|
||||||
{ role: "user", content: [{ type: "text", text: "Check both cities." }] },
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: "I'll check both." },
|
|
||||||
{ type: "tool_use", id: "call_paris", name: "weather", input: { city: "Paris" } },
|
|
||||||
{ type: "tool_use", id: "call_london", name: "weather", input: { city: "London" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: [
|
|
||||||
{ type: "tool_result", tool_use_id: "call_paris", content: '{"temperature":22}' },
|
|
||||||
{ type: "tool_result", tool_use_id: "call_london", content: '{"temperature":18}' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(prepared.body.messages).toHaveLength(3)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("keeps tools and sends tool_choice none", () =>
|
it.effect("keeps tools and sends tool_choice none", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
@@ -956,54 +915,6 @@ describe("Anthropic Messages route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("assembles and persists multiple tool calls from one Anthropic response", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
|
||||||
Effect.provide(
|
|
||||||
fixedResponse(
|
|
||||||
sseEvents(
|
|
||||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
|
||||||
{
|
|
||||||
type: "content_block_start",
|
|
||||||
index: 0,
|
|
||||||
content_block: { type: "tool_use", id: "call_paris", name: "weather", input: {} },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "content_block_delta",
|
|
||||||
index: 0,
|
|
||||||
delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' },
|
|
||||||
},
|
|
||||||
{ type: "content_block_stop", index: 0 },
|
|
||||||
{
|
|
||||||
type: "content_block_start",
|
|
||||||
index: 1,
|
|
||||||
content_block: { type: "tool_use", id: "call_london", name: "weather", input: {} },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "content_block_delta",
|
|
||||||
index: 1,
|
|
||||||
delta: { type: "input_json_delta", partial_json: '{"city":"London"}' },
|
|
||||||
},
|
|
||||||
{ type: "content_block_stop", index: 1 },
|
|
||||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 2 } },
|
|
||||||
{ type: "message_stop" },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response.toolCalls).toMatchObject([
|
|
||||||
{ id: "call_paris", name: "weather", input: { city: "Paris" } },
|
|
||||||
{ id: "call_london", name: "weather", input: { city: "London" } },
|
|
||||||
])
|
|
||||||
expect(response.message.content).toMatchObject([
|
|
||||||
{ type: "tool-call", id: "call_paris", name: "weather", input: { city: "Paris" } },
|
|
||||||
{ type: "tool-call", id: "call_london", name: "weather", input: { city: "London" } },
|
|
||||||
])
|
|
||||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_use" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("keeps malformed server tool input terminal", () =>
|
it.effect("keeps malformed server tool input terminal", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const body = sseEvents(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Ref, Schema, Stream } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import { HttpClientRequest } from "effect/unstable/http"
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
import {
|
import {
|
||||||
HttpOptions,
|
HttpOptions,
|
||||||
@@ -22,7 +22,7 @@ import { ProviderShared } from "../../src/protocols/shared.js"
|
|||||||
import { Auth, LLMClient } from "../../src/route.js"
|
import { Auth, LLMClient } from "../../src/route.js"
|
||||||
import { compileRequest } from "../../src/route/client.js"
|
import { compileRequest } from "../../src/route/client.js"
|
||||||
import { it } from "../lib/effect.js"
|
import { it } from "../lib/effect.js"
|
||||||
import { dynamicResponse, fixedResponse, systemError, truncatedStream } from "../lib/http.js"
|
import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http.js"
|
||||||
import { deltaChunk, usageChunk } from "../lib/openai-chunks.js"
|
import { deltaChunk, usageChunk } from "../lib/openai-chunks.js"
|
||||||
import { sseEvents } from "../lib/sse.js"
|
import { sseEvents } from "../lib/sse.js"
|
||||||
|
|
||||||
@@ -1221,44 +1221,12 @@ describe("OpenAI Chat route", () => {
|
|||||||
|
|
||||||
it.effect("surfaces transport errors that occur mid-stream", () =>
|
it.effect("surfaces transport errors that occur mid-stream", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const layer = truncatedStream(
|
const layer = truncatedStream([
|
||||||
[`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`],
|
`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`,
|
||||||
systemError("ECONNRESET", "socket closed unexpectedly"),
|
])
|
||||||
)
|
const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
|
||||||
const events = yield* Ref.make<ReadonlyArray<LLMEvent>>([])
|
|
||||||
const error = yield* LLMClient.stream(request).pipe(
|
|
||||||
Stream.tap((event) => Ref.update(events, (current) => [...current, event])),
|
|
||||||
Stream.runDrain,
|
|
||||||
Effect.provide(layer),
|
|
||||||
Effect.flip,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect((yield* Ref.get(events)).some((event) => event.type === "text-delta")).toBeTrue()
|
expect(error.message).toContain("Failed to read openai/openai-chat stream")
|
||||||
expect(error.reason).toMatchObject({
|
|
||||||
_tag: "Transport",
|
|
||||||
message: "ECONNRESET: socket closed unexpectedly",
|
|
||||||
transport: "http",
|
|
||||||
operation: "read",
|
|
||||||
code: "ECONNRESET",
|
|
||||||
url: "https://api.openai.test/v1/chat/completions",
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("surfaces transport errors before the first stream frame", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const error = yield* LLMClient.generate(request).pipe(
|
|
||||||
Effect.provide(truncatedStream([], systemError("ECONNRESET", "socket closed before output"))),
|
|
||||||
Effect.flip,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(error.reason).toMatchObject({
|
|
||||||
_tag: "Transport",
|
|
||||||
message: "ECONNRESET: socket closed before output",
|
|
||||||
transport: "http",
|
|
||||||
operation: "read",
|
|
||||||
code: "ECONNRESET",
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
|||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
||||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||||
import type { SessionInfo, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
|
import type { SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||||
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
|
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
|
||||||
import { expect, type Page } from "@playwright/test"
|
import { expect, type Page } from "@playwright/test"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
@@ -90,8 +90,6 @@ export async function setupTimeline(
|
|||||||
page: Page,
|
page: Page,
|
||||||
input: {
|
input: {
|
||||||
messages?: TimelineMessage[]
|
messages?: TimelineMessage[]
|
||||||
currentMessages?: SessionMessageInfo[]
|
|
||||||
sessionStatus?: Record<string, SessionStatus>
|
|
||||||
settings?: Record<string, boolean>
|
settings?: Record<string, boolean>
|
||||||
sessions?: Session[]
|
sessions?: Session[]
|
||||||
cpuRate?: number
|
cpuRate?: number
|
||||||
@@ -104,22 +102,13 @@ export async function setupTimeline(
|
|||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const sessions = input.sessions ?? [session()]
|
const sessions = input.sessions ?? [session()]
|
||||||
const messages =
|
const messages = validateTimelineMessages([
|
||||||
input.currentMessages ??
|
...(input.seedHistory ? historyMessages(18) : []),
|
||||||
validateTimelineMessages([
|
...(input.messages ?? [userMessage(), assistantMessage()]),
|
||||||
...(input.seedHistory ? historyMessages(18) : []),
|
])
|
||||||
...(input.messages ?? [userMessage(), assistantMessage()]),
|
const active = messages.findLast((message) => message.info.role === "assistant")
|
||||||
])
|
|
||||||
const active = messages.findLast((message) =>
|
|
||||||
"info" in message ? message.info.role === "assistant" : message.type === "assistant",
|
|
||||||
)
|
|
||||||
const initialStatus = decodeStatus(
|
const initialStatus = decodeStatus(
|
||||||
active &&
|
active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
|
||||||
("info" in active
|
|
||||||
? active.info.role === "assistant" && active.info.time.completed === undefined
|
|
||||||
: active.type === "assistant" && active.time.completed === undefined)
|
|
||||||
? { type: "busy" }
|
|
||||||
: { type: "idle" },
|
|
||||||
decodeOptions,
|
decodeOptions,
|
||||||
)
|
)
|
||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
@@ -133,7 +122,7 @@ export async function setupTimeline(
|
|||||||
project: project(),
|
project: project(),
|
||||||
provider: provider(),
|
provider: provider(),
|
||||||
sessions,
|
sessions,
|
||||||
sessionStatus: input.sessionStatus ?? { [sessionID]: initialStatus },
|
sessionStatus: { [sessionID]: initialStatus },
|
||||||
pageMessages: () => ({
|
pageMessages: () => ({
|
||||||
items: messages,
|
items: messages,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -122,7 +122,6 @@ test("applies message latency after a list response gate is released", async ()
|
|||||||
const gate = Promise.withResolvers<void>()
|
const gate = Promise.withResolvers<void>()
|
||||||
let handler: ((route: Route) => Promise<void>) | undefined
|
let handler: ((route: Route) => Promise<void>) | undefined
|
||||||
const page = {
|
const page = {
|
||||||
addInitScript: () => Promise.resolve(),
|
|
||||||
route: (_url: string, callback: (route: Route) => Promise<void>) => {
|
route: (_url: string, callback: (route: Route) => Promise<void>) => {
|
||||||
handler = callback
|
handler = callback
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { currentSession } from "../utils/mock-server"
|
import { currentSession } from "../utils/mock-server"
|
||||||
import { installSseTransport } from "../utils/sse-transport"
|
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
const serverB = "http://127.0.0.1:4097"
|
||||||
@@ -79,8 +78,6 @@ function session(id: string, directory: string, title: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function mockServers(page: Page, requests: string[]) {
|
async function mockServers(page: Page, requests: string[]) {
|
||||||
await installSseTransport(page, { server: serverA })
|
|
||||||
await installSseTransport(page, { server: serverB })
|
|
||||||
await page.route("**/*", async (route) => {
|
await page.route("**/*", async (route) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||||
@@ -88,6 +85,7 @@ async function mockServers(page: Page, requests: string[]) {
|
|||||||
const current = url.origin === serverA ? sessionA : sessionB
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
|
if (url.pathname === "/api/event") return sse(route)
|
||||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
@@ -144,3 +142,7 @@ function json(route: Route, body: unknown, status = 200) {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sse(route: Route) {
|
||||||
|
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ const sessionB = session("ses_server_b", directoryB, "Server B session")
|
|||||||
|
|
||||||
test("session settings use the remote server context", async ({ page }) => {
|
test("session settings use the remote server context", async ({ page }) => {
|
||||||
const permissionRequests: string[] = []
|
const permissionRequests: string[] = []
|
||||||
await installSseTransport(page, { server: serverA })
|
|
||||||
await installSseTransport(page, { server: serverB })
|
|
||||||
await mockServers(page, permissionRequests)
|
await mockServers(page, permissionRequests)
|
||||||
await configureServers(page)
|
await configureServers(page)
|
||||||
|
|
||||||
@@ -48,7 +46,6 @@ test("session settings use the remote server context", async ({ page }) => {
|
|||||||
test("auto-accept responds for an unfocused server session", async ({ page }) => {
|
test("auto-accept responds for an unfocused server session", async ({ page }) => {
|
||||||
const permissionRequests: string[] = []
|
const permissionRequests: string[] = []
|
||||||
const permissionResponses: PermissionResponse[] = []
|
const permissionResponses: PermissionResponse[] = []
|
||||||
await installSseTransport(page, { server: serverB })
|
|
||||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
||||||
server: serverA,
|
server: serverA,
|
||||||
retry: 20,
|
retry: 20,
|
||||||
@@ -184,6 +181,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
|||||||
return json(route, true)
|
return json(route, true)
|
||||||
}
|
}
|
||||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
|
if (url.pathname === "/api/event") return sse(route)
|
||||||
if (url.pathname === "/api/provider")
|
if (url.pathname === "/api/provider")
|
||||||
return json(route, {
|
return json(route, {
|
||||||
location: { directory },
|
location: { directory },
|
||||||
@@ -327,3 +325,7 @@ function json(route: Route, body: unknown, status = 200) {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sse(route: Route) {
|
||||||
|
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,23 +11,18 @@ const title = "Request dock regression"
|
|||||||
|
|
||||||
test("shows a pending question dock", async ({ page }) => {
|
test("shows a pending question dock", async ({ page }) => {
|
||||||
await mockServer(page, {
|
await mockServer(page, {
|
||||||
forms: [
|
questions: [
|
||||||
{
|
{
|
||||||
id: "frm_question_request",
|
id: "question-request",
|
||||||
sessionID,
|
sessionID,
|
||||||
title: "Questions",
|
questions: [
|
||||||
metadata: { kind: "question" },
|
|
||||||
fields: [
|
|
||||||
{
|
{
|
||||||
key: "q0",
|
header: "Implementation",
|
||||||
type: "string",
|
question: "Which implementation should be used?",
|
||||||
title: "Implementation",
|
|
||||||
description: "Which implementation should be used?",
|
|
||||||
options: [
|
options: [
|
||||||
{ value: "minimal", label: "Minimal", description: "Use the smallest correct change" },
|
{ label: "Minimal", description: "Use the smallest correct change" },
|
||||||
{ value: "extended", label: "Extended", description: "Include additional behavior" },
|
{ label: "Extended", description: "Include additional behavior" },
|
||||||
],
|
],
|
||||||
custom: true,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -47,7 +42,7 @@ test("shows a pending question dock", async ({ page }) => {
|
|||||||
const rejectRequests: string[] = []
|
const rejectRequests: string[] = []
|
||||||
page.on("request", (request) => {
|
page.on("request", (request) => {
|
||||||
if (request.method() !== "POST") return
|
if (request.method() !== "POST") return
|
||||||
if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`)
|
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
|
||||||
rejectRequests.push(request.url())
|
rejectRequests.push(request.url())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -72,10 +67,10 @@ test("shows a pending question dock", async ({ page }) => {
|
|||||||
const reply = page.waitForRequest(
|
const reply = page.waitForRequest(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.method() === "POST" &&
|
request.method() === "POST" &&
|
||||||
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/reply`,
|
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
|
||||||
)
|
)
|
||||||
await question.getByRole("button", { name: "Submit" }).click()
|
await question.getByRole("button", { name: "Submit" }).click()
|
||||||
expect((await reply).postDataJSON()).toEqual({ answer: { q0: "minimal" } })
|
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("shows a pending permission dock", async ({ page }) => {
|
test("shows a pending permission dock", async ({ page }) => {
|
||||||
@@ -114,7 +109,7 @@ test("restores the draft caret before typing after a request dock closes", async
|
|||||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||||
retry: 20,
|
retry: 20,
|
||||||
})
|
})
|
||||||
await mockServer(page, { forms: [] })
|
await mockServer(page, { questions: [] })
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await transport.waitForConnection()
|
await transport.waitForConnection()
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
@@ -140,24 +135,18 @@ test("restores the draft caret before typing after a request dock closes", async
|
|||||||
await transport.send({
|
await transport.send({
|
||||||
directory,
|
directory,
|
||||||
payload: {
|
payload: {
|
||||||
type: "form.created",
|
type: "question.asked",
|
||||||
properties: {
|
properties: {
|
||||||
form: {
|
id: "question-caret",
|
||||||
id: "frm_question_caret",
|
sessionID,
|
||||||
sessionID,
|
questions: [
|
||||||
title: "Questions",
|
{
|
||||||
metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } },
|
header: "Continue",
|
||||||
fields: [
|
question: "Continue?",
|
||||||
{
|
options: [{ label: "Yes", description: "Continue the session" }],
|
||||||
key: "q0",
|
},
|
||||||
type: "string",
|
],
|
||||||
title: "Continue",
|
tool: { messageID: "message-caret", callID: "call-caret" },
|
||||||
description: "Continue?",
|
|
||||||
options: [{ value: "yes", label: "Yes", description: "Continue the session" }],
|
|
||||||
custom: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -167,10 +156,7 @@ test("restores the draft caret before typing after a request dock closes", async
|
|||||||
|
|
||||||
await transport.send({
|
await transport.send({
|
||||||
directory,
|
directory,
|
||||||
payload: {
|
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
||||||
type: "form.cancelled",
|
|
||||||
properties: { sessionID, id: "frm_question_caret" },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
await expect(question).toHaveCount(0)
|
await expect(question).toHaveCount(0)
|
||||||
await expect(editor).toBeVisible()
|
await expect(editor).toBeVisible()
|
||||||
@@ -184,8 +170,6 @@ async function mockServer(
|
|||||||
requests: {
|
requests: {
|
||||||
permissions?: unknown[] | (() => unknown[])
|
permissions?: unknown[] | (() => unknown[])
|
||||||
questions?: unknown[] | (() => unknown[])
|
questions?: unknown[] | (() => unknown[])
|
||||||
forms?: unknown[] | (() => unknown[])
|
|
||||||
sessionStatus?: Record<string, unknown>
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
@@ -230,8 +214,6 @@ async function mockServer(
|
|||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
permissions: requests.permissions,
|
permissions: requests.permissions,
|
||||||
questions: requests.questions,
|
questions: requests.questions,
|
||||||
forms: requests.forms,
|
|
||||||
sessionStatus: requests.sessionStatus,
|
|
||||||
})
|
})
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
|
||||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
|
||||||
import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
|
|
||||||
|
|
||||||
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
|
|
||||||
|
|
||||||
const assistant = (completed: boolean, tool = false, childID?: string) =>
|
|
||||||
({
|
|
||||||
id: "msg_assistant",
|
|
||||||
type: "assistant",
|
|
||||||
agent: "build",
|
|
||||||
model: { id: "model", providerID: "provider" },
|
|
||||||
content: tool
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: "tool",
|
|
||||||
id: "call_subagent",
|
|
||||||
name: "subagent",
|
|
||||||
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
|
|
||||||
time: { created: 2 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [{ type: "text", text: "Working" }],
|
|
||||||
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
|
|
||||||
}) satisfies SessionMessageInfo
|
|
||||||
|
|
||||||
test("renders current protocol notices in CLI order", async ({ page }) => {
|
|
||||||
const ownerWarnings: string[] = []
|
|
||||||
page.on("console", (message) => {
|
|
||||||
if (message.text().includes("computations created outside a `createRoot` or `render`"))
|
|
||||||
ownerWarnings.push(message.text())
|
|
||||||
})
|
|
||||||
await setupTimeline(page, {
|
|
||||||
currentMessages: [
|
|
||||||
user,
|
|
||||||
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
|
|
||||||
assistant(true),
|
|
||||||
{
|
|
||||||
id: "msg_subagent",
|
|
||||||
type: "synthetic",
|
|
||||||
text: "done",
|
|
||||||
description: "Search code",
|
|
||||||
metadata: { source: "subagent", agent: "explore", state: "completed" },
|
|
||||||
time: { created: 4 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "msg_restart",
|
|
||||||
type: "synthetic",
|
|
||||||
text: "continue",
|
|
||||||
description: "Continuing after restart",
|
|
||||||
time: { created: 5 },
|
|
||||||
},
|
|
||||||
{ id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 6 } },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const notices = page.locator('[data-slot="session-timeline-notice"]')
|
|
||||||
await expect(notices).toHaveCount(4)
|
|
||||||
await expect(notices.nth(0)).toContainText("Agent · explore")
|
|
||||||
await expect(notices.nth(1)).toContainText("explore finished · Search code")
|
|
||||||
await expect(notices.nth(2)).toContainText("Continuing after restart")
|
|
||||||
await expect(notices.nth(3)).toContainText("Skill · Review")
|
|
||||||
expect(ownerWarnings).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
|
||||||
await setupTimeline(page, { currentMessages: [user, assistant(false, true)] })
|
|
||||||
await expect(page.locator('[data-component="task-tool-card"]')).toBeVisible()
|
|
||||||
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
|
|
||||||
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
|
|
||||||
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
|
|
||||||
|
|
||||||
const request = page.waitForRequest(
|
|
||||||
(request) =>
|
|
||||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
|
|
||||||
)
|
|
||||||
await page.keyboard.press("Control+b")
|
|
||||||
await request
|
|
||||||
})
|
|
||||||
|
|
||||||
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
|
|
||||||
const childID = "ses_running_child"
|
|
||||||
await setupTimeline(page, {
|
|
||||||
currentMessages: [user, assistant(false, true, childID)],
|
|
||||||
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
|
|
||||||
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
|
|
||||||
await page.locator('[data-component="task-tool-card"]').click()
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
|
|
||||||
await expect(page.locator('[data-component="session-background-dock"]')).toHaveCount(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("shows a badge for active background work", async ({ page }) => {
|
|
||||||
const childID = "ses_background_child"
|
|
||||||
await setupTimeline(page, {
|
|
||||||
currentMessages: [user, assistant(true)],
|
|
||||||
sessions: [session(), session({ id: childID, parentID: sessionID })],
|
|
||||||
sessionStatus: { [childID]: { type: "busy" } },
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(page.locator('[data-component="session-background-dock"]')).toContainText("1 subagent in background")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
|
|
||||||
const backgroundID = "ses_background_existing"
|
|
||||||
const blockingID = "ses_background_blocking"
|
|
||||||
await setupTimeline(page, {
|
|
||||||
currentMessages: [
|
|
||||||
user,
|
|
||||||
{
|
|
||||||
id: "msg_backgrounded",
|
|
||||||
type: "assistant",
|
|
||||||
agent: "build",
|
|
||||||
model: { id: "model", providerID: "provider" },
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "tool",
|
|
||||||
id: "call_backgrounded",
|
|
||||||
name: "subagent",
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: { description: "Background task" },
|
|
||||||
content: [{ type: "text", text: "working" }],
|
|
||||||
metadata: { sessionID: backgroundID, status: "running" },
|
|
||||||
},
|
|
||||||
time: { created: 2, completed: 3 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "tool",
|
|
||||||
id: "call_shell_backgrounded",
|
|
||||||
name: "shell",
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: { command: "sleep 120" },
|
|
||||||
content: [{ type: "text", text: "working" }],
|
|
||||||
metadata: { shellID: "shell_backgrounded", status: "running" },
|
|
||||||
},
|
|
||||||
time: { created: 2, completed: 3 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
time: { created: 2, completed: 3 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "msg_blocking",
|
|
||||||
type: "assistant",
|
|
||||||
agent: "build",
|
|
||||||
model: { id: "model", providerID: "provider" },
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "tool",
|
|
||||||
id: "call_blocking",
|
|
||||||
name: "subagent",
|
|
||||||
state: {
|
|
||||||
status: "running",
|
|
||||||
input: { description: "Foreground task" },
|
|
||||||
metadata: { sessionID: blockingID },
|
|
||||||
},
|
|
||||||
time: { created: 4 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
time: { created: 4 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
sessions: [
|
|
||||||
session(),
|
|
||||||
session({ id: backgroundID, parentID: sessionID, title: "Background task" }),
|
|
||||||
session({ id: blockingID, parentID: sessionID, title: "Foreground task" }),
|
|
||||||
],
|
|
||||||
sessionStatus: {
|
|
||||||
[sessionID]: { type: "busy" },
|
|
||||||
[backgroundID]: { type: "busy" },
|
|
||||||
[blockingID]: { type: "busy" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator('[data-component="session-background-dock"]')
|
|
||||||
await expect(dock).toContainText("Move 1 subagent to background")
|
|
||||||
await expect(dock.getByText("Running 1 shell and 1 subagent in background", { exact: true })).toBeVisible()
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
|
|
||||||
).toHaveAttribute("data-active", "true")
|
|
||||||
})
|
|
||||||
@@ -189,12 +189,7 @@ test.describe("smoke: session timeline", () => {
|
|||||||
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
||||||
?.getBoundingClientRect()
|
?.getBoundingClientRect()
|
||||||
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
|
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
|
||||||
if (
|
if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
|
||||||
!firstPaint &&
|
|
||||||
visible.includes(last) &&
|
|
||||||
Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1 &&
|
|
||||||
!root.querySelector('[data-markdown-key="initial"]')
|
|
||||||
) {
|
|
||||||
firstPaint = true
|
firstPaint = true
|
||||||
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
|
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
|
||||||
const rect = row.getBoundingClientRect()
|
const rect = row.getBoundingClientRect()
|
||||||
@@ -209,16 +204,10 @@ test.describe("smoke: session timeline", () => {
|
|||||||
}
|
}
|
||||||
;(
|
;(
|
||||||
window as Window & {
|
window as Window & {
|
||||||
__sessionTabPaint?: {
|
__sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
|
||||||
samples: typeof samples
|
|
||||||
painted: () => boolean
|
|
||||||
removed: () => number
|
|
||||||
stop: () => void
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
).__sessionTabPaint = {
|
).__sessionTabPaint = {
|
||||||
samples,
|
samples,
|
||||||
painted: () => firstPaint,
|
|
||||||
removed: () => removedFirstPaintNodes,
|
removed: () => removedFirstPaintNodes,
|
||||||
stop: () => {
|
stop: () => {
|
||||||
running = false
|
running = false
|
||||||
@@ -230,19 +219,17 @@ test.describe("smoke: session timeline", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
|
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() =>
|
||||||
const probe = (
|
(
|
||||||
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }>; painted: () => boolean } }
|
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
|
||||||
).__sessionTabPaint
|
).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
|
||||||
return probe?.painted() && probe.samples.some((sample) => sample.ids.length > 0)
|
)
|
||||||
})
|
|
||||||
await page.waitForTimeout(200)
|
await page.waitForTimeout(200)
|
||||||
const first = await page.evaluate(() => {
|
const first = await page.evaluate(() => {
|
||||||
const probe = (
|
const probe = (
|
||||||
window as Window & {
|
window as Window & {
|
||||||
__sessionTabPaint?: {
|
__sessionTabPaint?: {
|
||||||
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
|
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
|
||||||
painted: () => boolean
|
|
||||||
removed: () => number
|
removed: () => number
|
||||||
stop: () => void
|
stop: () => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,110 +32,15 @@ export interface MockServerConfig {
|
|||||||
todos?: (sessionID: string) => unknown[]
|
todos?: (sessionID: string) => unknown[]
|
||||||
permissions?: unknown[] | (() => unknown[])
|
permissions?: unknown[] | (() => unknown[])
|
||||||
questions?: unknown[] | (() => unknown[])
|
questions?: unknown[] | (() => unknown[])
|
||||||
forms?: unknown[] | (() => unknown[])
|
|
||||||
fileList?: (path: string) => unknown | Promise<unknown>
|
fileList?: (path: string) => unknown | Promise<unknown>
|
||||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||||
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
||||||
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
|
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockStreamWindow = Window & {
|
|
||||||
__testSseTransport?: unknown
|
|
||||||
__mockServerStream?: { push: (payloads: unknown[]) => void }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
const cursors = new Map<string, string>()
|
const cursors = new Map<string, string>()
|
||||||
let nextCursor = 0
|
let nextCursor = 0
|
||||||
|
|
||||||
await page.addInitScript(
|
|
||||||
({ port, retry }) => {
|
|
||||||
const host = window as MockStreamWindow
|
|
||||||
if (host.__testSseTransport || host.__mockServerStream) return
|
|
||||||
const originalFetch = window.fetch.bind(window)
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
const state: {
|
|
||||||
controller?: ReadableStreamDefaultController<Uint8Array>
|
|
||||||
buffer: string[]
|
|
||||||
connections: number
|
|
||||||
} = { buffer: [], connections: 0 }
|
|
||||||
const frame = (payload: unknown) => `data: ${JSON.stringify(payload)}\n\n`
|
|
||||||
host.__mockServerStream = {
|
|
||||||
push(payloads: unknown[]) {
|
|
||||||
const frames = payloads.map(frame)
|
|
||||||
const controller = state.controller
|
|
||||||
if (!controller) {
|
|
||||||
state.buffer.push(...frames)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
frames.forEach((item) => controller.enqueue(encoder.encode(item)))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
const request = new Request(input, init)
|
|
||||||
const url = new URL(request.url)
|
|
||||||
if (url.port !== port || url.pathname !== "/api/event") return originalFetch(request)
|
|
||||||
state.connections += 1
|
|
||||||
const id = state.connections
|
|
||||||
let ended = false
|
|
||||||
let own: ReadableStreamDefaultController<Uint8Array> | undefined
|
|
||||||
const stream = new ReadableStream<Uint8Array>({
|
|
||||||
start(controller) {
|
|
||||||
own = controller
|
|
||||||
state.controller = controller
|
|
||||||
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
|
||||||
controller.enqueue(
|
|
||||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
|
||||||
)
|
|
||||||
state.buffer.splice(0).forEach((item) => controller.enqueue(encoder.encode(item)))
|
|
||||||
request.signal.addEventListener(
|
|
||||||
"abort",
|
|
||||||
() => {
|
|
||||||
if (ended) return
|
|
||||||
ended = true
|
|
||||||
if (state.controller === controller) state.controller = undefined
|
|
||||||
controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
if (ended) return
|
|
||||||
ended = true
|
|
||||||
if (state.controller === own) state.controller = undefined
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(stream, {
|
|
||||||
status: 200,
|
|
||||||
headers: { "cache-control": "no-cache", "content-type": "text/event-stream" },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
|
|
||||||
},
|
|
||||||
{ port: process.env.PLAYWRIGHT_SERVER_PORT ?? "4096", retry: config.eventRetry },
|
|
||||||
)
|
|
||||||
|
|
||||||
if (config.events) {
|
|
||||||
const pump = { busy: false }
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
if (pump.busy) return
|
|
||||||
const batch = config.events?.() ?? []
|
|
||||||
if (batch.length === 0) return
|
|
||||||
pump.busy = true
|
|
||||||
void page
|
|
||||||
.evaluate(
|
|
||||||
(payloads) => (window as MockStreamWindow).__mockServerStream?.push(payloads),
|
|
||||||
batch.map(currentEvent),
|
|
||||||
)
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => {
|
|
||||||
pump.busy = false
|
|
||||||
})
|
|
||||||
}, 50)
|
|
||||||
page.on("close", () => clearInterval(timer))
|
|
||||||
}
|
|
||||||
const staticRoutes: Record<string, unknown> = {
|
const staticRoutes: Record<string, unknown> = {
|
||||||
"/path": {
|
"/path": {
|
||||||
state: config.directory,
|
state: config.directory,
|
||||||
@@ -290,11 +195,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
location: location(config),
|
location: location(config),
|
||||||
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
|
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
|
||||||
})
|
})
|
||||||
if (path === "/api/form/request")
|
|
||||||
return json(route, {
|
|
||||||
location: location(config),
|
|
||||||
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
|
|
||||||
})
|
|
||||||
if (path === "/api/vcs")
|
if (path === "/api/vcs")
|
||||||
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
|
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
|
||||||
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
||||||
@@ -380,18 +280,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
}
|
}
|
||||||
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
|
|
||||||
if (sessionForm && route.request().method() === "GET") {
|
|
||||||
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
|
|
||||||
return json(route, { data: forms.filter((form) => (form as { sessionID?: string }).sessionID === sessionForm) })
|
|
||||||
}
|
|
||||||
if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") {
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
}
|
|
||||||
if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST")
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
|
|
||||||
return json(route, { data: [] })
|
|
||||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-23
@@ -54,7 +54,7 @@ import { useCheckServerHealth } from "./utils/server-health"
|
|||||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
import { SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
import { TargetSessionRouteContent } from "@/pages/session"
|
||||||
import { Home } from "@/pages/home"
|
import { Home } from "@/pages/home"
|
||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
@@ -86,23 +86,18 @@ function TargetServerRoute(props: ParentProps) {
|
|||||||
return (
|
return (
|
||||||
// Owns the server-identity remount. Session changes must not remount this subtree.
|
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||||
<ServerSDKProvider server={conn()}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn()}>{props.children}</ServerSyncProvider>
|
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
||||||
</ServerSDKProvider>
|
</ServerSDKProvider>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TargetSessionRoute() {
|
const TargetSessionRoute = () => (
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
<TargetServerRoute>
|
||||||
return (
|
<TargetSessionRouteContent />
|
||||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
</TargetServerRoute>
|
||||||
<TargetServerRoute>
|
)
|
||||||
<TargetSessionRouteContent />
|
|
||||||
</TargetServerRoute>
|
|
||||||
</SessionRouteErrorBoundary>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||||
// server via ServerKey, then provide the server-scoped shell for that server.
|
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||||
@@ -135,14 +130,16 @@ function DraftRoute() {
|
|||||||
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
|
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
|
||||||
|
const directory = () => props.draft.directory
|
||||||
|
const serverKey = () => props.draft.server
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||||
<ServerSDKProvider server={conn()}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn()}>
|
<ServerSyncProvider server={conn}>
|
||||||
<ModelsProvider directory={props.draft.directory}>
|
<ModelsProvider directory={directory}>
|
||||||
<SDKProvider directory={props.draft.directory}>
|
<SDKProvider directory={directory}>
|
||||||
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}>
|
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||||
<DraftProviders>
|
<DraftProviders>
|
||||||
<NewSession />
|
<NewSession />
|
||||||
</DraftProviders>
|
</DraftProviders>
|
||||||
@@ -240,7 +237,7 @@ function DesktopCommands() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ServerScopedShellProps = ParentProps<{
|
type ServerScopedShellProps = ParentProps<{
|
||||||
directory?: string
|
directory?: () => string | undefined
|
||||||
serverScoped?: JSX.Element
|
serverScoped?: JSX.Element
|
||||||
}>
|
}>
|
||||||
|
|
||||||
@@ -453,10 +450,12 @@ export function AppInterface(props: {
|
|||||||
// route changes. Draft and session routes override only their server-bound data
|
// route changes. Draft and session routes override only their server-bound data
|
||||||
// providers beneath it.
|
// providers beneath it.
|
||||||
const ServerShell = (shellProps: ParentProps) => (
|
const ServerShell = (shellProps: ParentProps) => (
|
||||||
<SharedProviders>
|
<QueryProvider>
|
||||||
{props.children}
|
<SharedProviders>
|
||||||
{shellProps.children}
|
{props.children}
|
||||||
</SharedProviders>
|
{shellProps.children}
|
||||||
|
</SharedProviders>
|
||||||
|
</QueryProvider>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useIsRouting, useLocation } from "@solidjs/router"
|
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||||
import { batch, createEffect, onCleanup, onMount, Show } from "solid-js"
|
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
@@ -571,7 +571,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
value={language.t(`debugBar.direction.${language.direction()}`)}
|
value={language.t(`debugBar.direction.${language.direction()}`)}
|
||||||
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
|
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
|
||||||
/>
|
/>
|
||||||
<Show when={platform.setForceFocus}>
|
{platform.setForceFocus && (
|
||||||
<ToggleCell
|
<ToggleCell
|
||||||
active={state.focus}
|
active={state.focus}
|
||||||
inline={props.inline}
|
inline={props.inline}
|
||||||
@@ -580,7 +580,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
|
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
|
||||||
onClick={() => void toggleFocus()}
|
onClick={() => void toggleFocus()}
|
||||||
/>
|
/>
|
||||||
</Show>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
|||||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
|
import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { ExternalLink } from "@/components/external-link"
|
import { ExternalLink } from "@/components/external-link"
|
||||||
@@ -20,7 +20,6 @@ import { useServerSync } from "@/context/server-sync"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { useIntegrations } from "@/hooks/use-integrations"
|
|
||||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller"
|
import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller"
|
||||||
@@ -41,7 +40,7 @@ export function useProviderConnectController(options: { onBack?: () => void } =
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DialogConnectProvider: Component<{
|
export const DialogConnectProvider: Component<{
|
||||||
directory?: string
|
directory?: Accessor<string | undefined>
|
||||||
controller?: ReturnType<typeof useProviderConnectController>
|
controller?: ReturnType<typeof useProviderConnectController>
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const fallback = useProviderConnectController()
|
const fallback = useProviderConnectController()
|
||||||
@@ -137,11 +136,15 @@ export const DialogConnectProvider: Component<{
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProviderPicker(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
|
function ProviderPicker(props: {
|
||||||
|
directory?: Accessor<string | undefined>
|
||||||
|
onSelect: (provider: string) => void
|
||||||
|
onPrepare?: () => void
|
||||||
|
}) {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
if (settings.general.newLayoutDesigns())
|
if (settings.general.newLayoutDesigns())
|
||||||
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
||||||
const integrations = useIntegrations(() => props.directory)
|
const providers = useProviders(() => props.directory?.())
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||||
@@ -163,7 +166,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
|||||||
key={(x) => x?.id}
|
key={(x) => x?.id}
|
||||||
items={() => {
|
items={() => {
|
||||||
language.locale()
|
language.locale()
|
||||||
return [{ id: CUSTOM_ID, name: customLabel() }, ...integrations.list()]
|
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
|
||||||
}}
|
}}
|
||||||
filterKeys={["id", "name"]}
|
filterKeys={["id", "name"]}
|
||||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
||||||
@@ -208,8 +211,12 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProviderPickerV2(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
|
function ProviderPickerV2(props: {
|
||||||
const integrations = useIntegrations(() => props.directory)
|
directory?: Accessor<string | undefined>
|
||||||
|
onSelect: (provider: string) => void
|
||||||
|
onPrepare?: () => void
|
||||||
|
}) {
|
||||||
|
const providers = useProviders(() => props.directory?.())
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
filter: "",
|
filter: "",
|
||||||
@@ -221,7 +228,7 @@ function ProviderPickerV2(props: { directory?: string; onSelect: (provider: stri
|
|||||||
const all = createMemo(() => {
|
const all = createMemo(() => {
|
||||||
language.locale()
|
language.locale()
|
||||||
const query = store.filter.trim().toLowerCase()
|
const query = store.filter.trim().toLowerCase()
|
||||||
const values = [custom(), ...integrations.list()]
|
const values = [custom(), ...providers.all().values()]
|
||||||
if (!query) return values
|
if (!query) return values
|
||||||
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
|
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
|
||||||
})
|
})
|
||||||
@@ -357,7 +364,7 @@ function ProviderPickerV2(props: { directory?: string; onSelect: (provider: stri
|
|||||||
|
|
||||||
function ProviderConnection(props: {
|
function ProviderConnection(props: {
|
||||||
provider: string
|
provider: string
|
||||||
directory?: string
|
directory?: Accessor<string | undefined>
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
setBack: (handler: () => void) => void
|
setBack: (handler: () => void) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -367,9 +374,12 @@ function ProviderConnection(props: {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const newLayout = settings.general.newLayoutDesigns
|
const newLayout = settings.general.newLayoutDesigns
|
||||||
const providers = useProviders(() => props.directory)
|
const providers = useProviders(() => props.directory?.())
|
||||||
const directory = () => props.directory ?? decode64(params.dir)
|
const directory = () => props.directory?.() ?? decode64(params.dir)
|
||||||
|
|
||||||
|
const provider = createMemo(
|
||||||
|
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||||
|
)
|
||||||
const controller = createProviderConnectionController({
|
const controller = createProviderConnectionController({
|
||||||
provider: () => props.provider,
|
provider: () => props.provider,
|
||||||
directory,
|
directory,
|
||||||
@@ -383,14 +393,6 @@ function ProviderConnection(props: {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const provider = createMemo(() => ({
|
|
||||||
id: props.provider,
|
|
||||||
name:
|
|
||||||
providers.all().get(props.provider)?.name ??
|
|
||||||
serverSync().data.provider.all.get(props.provider)?.name ??
|
|
||||||
controller.integration()?.name ??
|
|
||||||
props.provider,
|
|
||||||
}))
|
|
||||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||||
if (!value) return ""
|
if (!value) return ""
|
||||||
if (value.type === "key") return language.t("provider.connect.method.apiKey")
|
if (value.type === "key") return language.t("provider.connect.method.apiKey")
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
.project-settings-v2-dialog [data-slot="dialog-container"] {
|
|
||||||
background: var(--v2-background-bg-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-v2-dialog [data-slot="dialog-body"] {
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-v2 {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-v2-nav {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-v2-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden !important;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
|
|
||||||
user-select: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-v2-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-v2-scroll {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 24px;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 40px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-page-header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-page-header h2 {
|
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 640;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-page-header > span {
|
|
||||||
color: var(--v2-text-text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 440;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extensions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-tabs {
|
|
||||||
flex: 1;
|
|
||||||
height: auto;
|
|
||||||
min-height: 0;
|
|
||||||
margin-top: 24px;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
|
|
||||||
width: auto;
|
|
||||||
padding-inline: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
|
|
||||||
> [data-slot="tabs-v2-list"]
|
|
||||||
[data-slot="tabs-v2-trigger-wrapper"] {
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
|
|
||||||
> [data-slot="tabs-v2-list"]
|
|
||||||
[data-slot="tabs-v2-trigger"] {
|
|
||||||
padding-inline: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-tabs [data-slot="tabs-v2-content"] {
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
padding-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-section-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 530;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-section-header > :last-child {
|
|
||||||
color: var(--v2-text-text-faint);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 440;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-link {
|
|
||||||
color: var(--v2-text-text-accent) !important;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-card {
|
|
||||||
padding-inline: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 0.5px solid var(--v2-border-border-base);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--v2-background-bg-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
min-height: 48px;
|
|
||||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-row:last-child {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-row-main {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-row-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--v2-icon-icon-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-row-name {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 530;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-row-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--v2-text-text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-extension-row-status-dot {
|
|
||||||
width: 5px;
|
|
||||||
height: 5px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--v2-state-fg-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-shared {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-shared-trigger {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
align-self: flex-start;
|
|
||||||
gap: 6px;
|
|
||||||
color: var(--v2-text-text-base);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 530;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-shared-chevron {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
color: var(--v2-icon-icon-muted);
|
|
||||||
transition: transform 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-shared-chevron.open {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-settings-shared-count {
|
|
||||||
padding: 1px 4px;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: var(--v2-background-bg-layer-02);
|
|
||||||
color: var(--v2-text-text-muted);
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
@@ -1,223 +1,156 @@
|
|||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
|
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
|
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||||
import { Field } from "@opencode-ai/ui/v2/field-v2"
|
import { Field } from "@opencode-ai/ui/v2/field-v2"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
|
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
|
||||||
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
|
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { For, Show, createSignal, startTransition } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
import { createEditProjectModel } from "./edit-project"
|
import { createEditProjectModel } from "./edit-project"
|
||||||
import { ProjectSettingsExtensions } from "./project-settings-extensions"
|
|
||||||
import { SettingsServerDataScope } from "./settings-server-picker"
|
|
||||||
import "./settings-v2/settings-v2.css"
|
|
||||||
import "./dialog-edit-project-v2.css"
|
|
||||||
|
|
||||||
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
return (
|
|
||||||
<SettingsServerDataScope server={props.server}>
|
|
||||||
<SDKProvider directory={props.project.worktree}>
|
|
||||||
<ProjectSettingsDialog project={props.project} server={props.server} />
|
|
||||||
</SDKProvider>
|
|
||||||
</SettingsServerDataScope>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProjectSettingsDialog(props: { project: LocalProject; server: ServerConnection.Any }) {
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const model = createEditProjectModel(props)
|
const model = createEditProjectModel(props)
|
||||||
const projectName = () => displayName(props.project)
|
|
||||||
const [tab, setTab] = createSignal("general")
|
|
||||||
|
|
||||||
const Footer = () => (
|
|
||||||
<DialogFooter>
|
|
||||||
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
|
||||||
{language.t("common.cancel")}
|
|
||||||
</ButtonV2>
|
|
||||||
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
|
|
||||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
|
||||||
</ButtonV2>
|
|
||||||
</DialogFooter>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog size="x-large" variant="settings" class="project-settings-v2-dialog">
|
<Dialog fit>
|
||||||
<TabsV2
|
<form onSubmit={model.submit} class="contents">
|
||||||
orientation="vertical"
|
<DialogHeader>
|
||||||
variant="settings"
|
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
|
||||||
value={tab()}
|
</DialogHeader>
|
||||||
onChange={(value) => void startTransition(() => setTab(value))}
|
<DividerV2 />
|
||||||
class="project-settings-v2"
|
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
|
||||||
>
|
<Field>
|
||||||
<TabsV2.List>
|
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
||||||
<div class="project-settings-v2-nav">
|
<TextInputV2
|
||||||
<TabsV2.Trigger value="general">
|
autofocus
|
||||||
<ProjectAvatar
|
appearance="large"
|
||||||
fallback={projectName()}
|
class="!w-full"
|
||||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
value={model.store.name}
|
||||||
class="!size-4 shrink-0"
|
placeholder={model.folderName()}
|
||||||
/>
|
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
||||||
<span class="truncate">{projectName()}</span>
|
/>
|
||||||
</TabsV2.Trigger>
|
</Field>
|
||||||
<TabsV2.Trigger value="scripts">
|
|
||||||
<Icon name="code" size="small" />
|
|
||||||
{language.t("project.settings.scripts")}
|
|
||||||
</TabsV2.Trigger>
|
|
||||||
<TabsV2.Trigger value="extensions">
|
|
||||||
<Icon name="extensions" size="small" />
|
|
||||||
{language.t("settings.tab.extensions")}
|
|
||||||
</TabsV2.Trigger>
|
|
||||||
</div>
|
|
||||||
</TabsV2.List>
|
|
||||||
|
|
||||||
<TabsV2.Content value="general" class="project-settings-v2-panel">
|
<div class="flex w-full flex-col gap-2">
|
||||||
<form onSubmit={model.submit} class="project-settings-v2-form">
|
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||||
<div class="project-settings-v2-scroll">
|
{language.t("dialog.project.edit.icon")}
|
||||||
<div class="project-settings-page-header">
|
</div>
|
||||||
<h2>{language.t("dialog.project.edit.title")}</h2>
|
<div class="flex items-center gap-3">
|
||||||
<span>{language.t("project.settings.general.description")}</span>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
aria-label={language.t("dialog.project.edit.icon.alt")}
|
||||||
<Field>
|
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
||||||
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
classList={{
|
||||||
<TextInputV2
|
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
||||||
autofocus
|
}}
|
||||||
appearance="large"
|
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||||
class="!w-full"
|
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||||
value={model.store.name}
|
onDrop={model.drop}
|
||||||
placeholder={model.folderName()}
|
onDragOver={model.dragOver}
|
||||||
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
onDragLeave={model.dragLeave}
|
||||||
|
onClick={model.iconClick}
|
||||||
|
>
|
||||||
|
<ProjectAvatar
|
||||||
|
fallback={model.store.name || model.defaultName()}
|
||||||
|
src={getProjectAvatarSource(props.project.id, {
|
||||||
|
color: model.store.color,
|
||||||
|
url: props.project.icon?.url,
|
||||||
|
override: model.store.iconOverride,
|
||||||
|
})}
|
||||||
|
variant={getProjectAvatarVariant(model.store.color)}
|
||||||
|
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
||||||
/>
|
/>
|
||||||
</Field>
|
<span
|
||||||
|
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
||||||
|
classList={{
|
||||||
|
"opacity-100": model.store.iconHover,
|
||||||
|
"opacity-0": !model.store.iconHover,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={(element) => {
|
||||||
|
model.setIconInput(element)
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
onChange={model.inputChange}
|
||||||
|
/>
|
||||||
|
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||||
|
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||||
|
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-2">
|
<Show when={!model.store.iconOverride}>
|
||||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
<div class="flex w-full flex-col gap-2">
|
||||||
{language.t("dialog.project.edit.icon")}
|
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||||
</div>
|
{language.t("dialog.project.edit.color")}
|
||||||
<div class="flex items-center gap-3">
|
</div>
|
||||||
<button
|
<div class="-ml-1 flex gap-1.5">
|
||||||
type="button"
|
<For each={PROJECT_AVATAR_VARIANTS}>
|
||||||
aria-label={language.t("dialog.project.edit.icon.alt")}
|
{(color) => (
|
||||||
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
<button
|
||||||
classList={{
|
type="button"
|
||||||
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||||
}}
|
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
||||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
||||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
|
||||||
onDrop={model.drop}
|
|
||||||
onDragOver={model.dragOver}
|
|
||||||
onDragLeave={model.dragLeave}
|
|
||||||
onClick={model.iconClick}
|
|
||||||
>
|
|
||||||
<ProjectAvatar
|
|
||||||
fallback={model.store.name || model.defaultName()}
|
|
||||||
src={getProjectAvatarSource(props.project.id, {
|
|
||||||
color: model.store.color,
|
|
||||||
url: props.project.icon?.url,
|
|
||||||
override: model.store.iconOverride,
|
|
||||||
})}
|
|
||||||
variant={getProjectAvatarVariant(model.store.color)}
|
|
||||||
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
|
||||||
classList={{
|
classList={{
|
||||||
"opacity-100": model.store.iconHover,
|
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
||||||
"opacity-0": !model.store.iconHover,
|
getProjectAvatarVariant(model.store.color) === color,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
||||||
|
model.setStore(
|
||||||
|
"color",
|
||||||
|
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconV2 name={model.store.iconOverride ? "close" : "share"} />
|
<ProjectAvatar
|
||||||
</span>
|
fallback={model.store.name || model.defaultName()}
|
||||||
</button>
|
variant={getProjectAvatarVariant(color)}
|
||||||
<input
|
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
||||||
ref={(element) => model.setIconInput(element)}
|
/>
|
||||||
type="file"
|
</button>
|
||||||
accept="image/*"
|
)}
|
||||||
class="hidden"
|
</For>
|
||||||
onChange={model.inputChange}
|
|
||||||
/>
|
|
||||||
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
|
||||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
|
||||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={!model.store.iconOverride}>
|
|
||||||
<div class="flex w-full flex-col gap-2">
|
|
||||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
|
||||||
{language.t("dialog.project.edit.color")}
|
|
||||||
</div>
|
|
||||||
<div class="-ml-1 flex gap-1.5">
|
|
||||||
<For each={PROJECT_AVATAR_VARIANTS}>
|
|
||||||
{(color) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
|
||||||
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
|
||||||
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
|
||||||
classList={{
|
|
||||||
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
|
||||||
getProjectAvatarVariant(model.store.color) === color,
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
|
||||||
model.setStore(
|
|
||||||
"color",
|
|
||||||
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ProjectAvatar
|
|
||||||
fallback={model.store.name || model.defaultName()}
|
|
||||||
variant={getProjectAvatarVariant(color)}
|
|
||||||
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
</Show>
|
||||||
</form>
|
|
||||||
</TabsV2.Content>
|
|
||||||
|
|
||||||
<TabsV2.Content value="scripts" class="project-settings-v2-panel">
|
<Field>
|
||||||
<form onSubmit={model.submit} class="project-settings-v2-form">
|
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
||||||
<div class="project-settings-v2-scroll">
|
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
||||||
<div class="project-settings-page-header">
|
<TextareaV2
|
||||||
<h2>{language.t("project.settings.scripts")}</h2>
|
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
||||||
<span>{language.t("project.settings.scripts.description")}</span>
|
rows={3}
|
||||||
</div>
|
value={model.store.startup}
|
||||||
<Field>
|
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||||
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
spellcheck={false}
|
||||||
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
||||||
<TextareaV2
|
/>
|
||||||
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
</Field>
|
||||||
rows={5}
|
</DialogBody>
|
||||||
value={model.store.startup}
|
<DialogFooter>
|
||||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||||
spellcheck={false}
|
{language.t("common.cancel")}
|
||||||
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
</ButtonV2>
|
||||||
/>
|
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
|
||||||
</Field>
|
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||||
</div>
|
</ButtonV2>
|
||||||
<Footer />
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</TabsV2.Content>
|
|
||||||
|
|
||||||
<TabsV2.Content value="extensions" class="project-settings-v2-panel">
|
|
||||||
<ProjectSettingsExtensions />
|
|
||||||
</TabsV2.Content>
|
|
||||||
</TabsV2>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
|
|||||||
const directory = () => decode64(local.slug())
|
const directory = () => decode64(local.slug())
|
||||||
|
|
||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
|
void dialog.show(() => <DialogConnectProvider directory={directory} />)
|
||||||
}
|
}
|
||||||
const providerRank = (id: string) => popularProviders.indexOf(id)
|
const providerRank = (id: string) => popularProviders.indexOf(id)
|
||||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||||
@@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => {
|
|||||||
const directory = () => decode64(local.slug())
|
const directory = () => decode64(local.slug())
|
||||||
|
|
||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
|
void dialog.show(() => <DialogConnectProvider directory={directory} />)
|
||||||
}
|
}
|
||||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||||
const providerVisible = (providerID: string) =>
|
const providerVisible = (providerID: string) =>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, Index, Show } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -83,71 +83,61 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
|||||||
{/* Bottom section - buttons and indicators (fixed position) */}
|
{/* Bottom section - buttons and indicators (fixed position) */}
|
||||||
<div class="flex flex-col gap-12">
|
<div class="flex flex-col gap-12">
|
||||||
<div class="flex flex-col items-start gap-3">
|
<div class="flex flex-col items-start gap-3">
|
||||||
<Show
|
{isLast() ? (
|
||||||
when={isLast()}
|
|
||||||
fallback={
|
|
||||||
<Button variant="secondary" size="large" onClick={handleNext}>
|
|
||||||
{language.t("dialog.releaseNotes.action.next")}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button variant="primary" size="large" onClick={handleClose}>
|
<Button variant="primary" size="large" onClick={handleClose}>
|
||||||
{language.t("dialog.releaseNotes.action.getStarted")}
|
{language.t("dialog.releaseNotes.action.getStarted")}
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
) : (
|
||||||
|
<Button variant="secondary" size="large" onClick={handleNext}>
|
||||||
|
{language.t("dialog.releaseNotes.action.next")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button variant="ghost" size="small" onClick={handleDisable}>
|
<Button variant="ghost" size="small" onClick={handleDisable}>
|
||||||
{language.t("dialog.releaseNotes.action.hideFuture")}
|
{language.t("dialog.releaseNotes.action.hideFuture")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={paged()}>
|
{paged() && (
|
||||||
<div class="flex items-center gap-1.5 -my-2.5">
|
<div class="flex items-center gap-1.5 -my-2.5">
|
||||||
<Index each={props.highlights}>
|
{props.highlights.map((_, i) => (
|
||||||
{(_, i) => (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
|
||||||
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
|
classList={{
|
||||||
|
"w-8": i === index(),
|
||||||
|
"w-3": i !== index(),
|
||||||
|
}}
|
||||||
|
onClick={() => setIndex(i)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
|
||||||
classList={{
|
classList={{
|
||||||
"w-8": i === index(),
|
"bg-icon-strong-base": i === index(),
|
||||||
"w-3": i !== index(),
|
"bg-icon-weak-base": i !== index(),
|
||||||
}}
|
}}
|
||||||
onClick={() => setIndex(i)}
|
/>
|
||||||
>
|
</button>
|
||||||
<div
|
))}
|
||||||
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
|
|
||||||
classList={{
|
|
||||||
"bg-icon-strong-base": i === index(),
|
|
||||||
"bg-icon-weak-base": i !== index(),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</Index>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Media content (edge to edge) */}
|
{/* Right side - Media content (edge to edge) */}
|
||||||
<Show when={feature()?.media}>
|
{feature()?.media && (
|
||||||
{(media) => (
|
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
|
||||||
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
|
{feature()!.media!.type === "image" ? (
|
||||||
<Show
|
<img
|
||||||
when={media().type === "image"}
|
src={feature()!.media!.src}
|
||||||
fallback={
|
alt={feature()!.media!.alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
|
||||||
<video src={media().src} autoplay loop muted playsinline class="w-full h-full object-cover" />
|
class="w-full h-full object-cover"
|
||||||
}
|
/>
|
||||||
>
|
) : (
|
||||||
<img
|
<video src={feature()!.media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
|
||||||
src={media().src}
|
)}
|
||||||
alt={media().alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
|
</div>
|
||||||
class="w-full h-full object-cover"
|
)}
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { useTheme } from "@opencode-ai/ui/theme"
|
import { useTheme } from "@opencode-ai/ui/theme"
|
||||||
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useIntegrations } from "@/hooks/use-integrations"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
@@ -22,7 +22,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const directory = () => decode64(local.slug())
|
const directory = () => decode64(local.slug())
|
||||||
const integrations = useIntegrations(directory)
|
const providers = useProviders(directory)
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
|
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
|
||||||
const currentKey = createMemo(() => {
|
const currentKey = createMemo(() => {
|
||||||
@@ -37,7 +37,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
void import("./dialog-connect-provider").then((x) => {
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
const controller = x.useProviderConnectController()
|
const controller = x.useProviderConnectController()
|
||||||
controller.select(provider)
|
controller.select(provider)
|
||||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />)
|
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,8 +127,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
|
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
|
||||||
<For
|
<For
|
||||||
each={integrations
|
each={[...providers.popular()]
|
||||||
.list()
|
|
||||||
.filter((provider) => featuredProviders.includes(provider.id))
|
.filter((provider) => featuredProviders.includes(provider.id))
|
||||||
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
|
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
|||||||
void import("./dialog-connect-provider").then((x) => {
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
const controller = x.useProviderConnectController()
|
const controller = x.useProviderConnectController()
|
||||||
controller.select(provider)
|
controller.select(provider)
|
||||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />)
|
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export function ModelSelectorPopover(props: {
|
|||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
close("provider")
|
close("provider")
|
||||||
void import("./dialog-connect-provider").then((x) => {
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
|
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -240,7 +240,7 @@ export function ModelSelectorPopoverV2(props: {
|
|||||||
trigger={props.trigger}
|
trigger={props.trigger}
|
||||||
models={controller.models}
|
models={controller.models}
|
||||||
groups={controller.groups}
|
groups={controller.groups}
|
||||||
current={controller.current()}
|
current={controller.current}
|
||||||
select={controller.select}
|
select={controller.select}
|
||||||
onManage={() => {
|
onManage={() => {
|
||||||
void import("./dialog-manage-models").then((module) => {
|
void import("./dialog-manage-models").then((module) => {
|
||||||
@@ -295,7 +295,7 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
trigger: ModelSelectorTrigger
|
trigger: ModelSelectorTrigger
|
||||||
models: (search: string) => ModelItem[]
|
models: (search: string) => ModelItem[]
|
||||||
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||||
current: string | undefined
|
current: () => string | undefined
|
||||||
select: (item: ModelItem) => void
|
select: (item: ModelItem) => void
|
||||||
onManage: () => void
|
onManage: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -310,7 +310,7 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
const groups = createMemo(() => props.groups(models()))
|
const groups = createMemo(() => props.groups(models()))
|
||||||
const keys = () => [...models().map(modelKey), manageKey]
|
const keys = () => [...models().map(modelKey), manageKey]
|
||||||
const initialActive = () => {
|
const initialActive = () => {
|
||||||
const selected = props.current
|
const selected = props.current()
|
||||||
const options = keys()
|
const options = keys()
|
||||||
if (selected && options.includes(selected)) return selected
|
if (selected && options.includes(selected)) return selected
|
||||||
return options[0] ?? ""
|
return options[0] ?? ""
|
||||||
@@ -453,7 +453,7 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
<MenuV2.GroupLabel class="gap-2 px-3">
|
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||||
</MenuV2.GroupLabel>
|
</MenuV2.GroupLabel>
|
||||||
<MenuV2.RadioGroup value={props.current}>
|
<MenuV2.RadioGroup value={props.current()}>
|
||||||
<For each={group.items}>
|
<For each={group.items}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<TooltipV2
|
<TooltipV2
|
||||||
@@ -473,7 +473,7 @@ function ModelSelectorPopoverV2View(props: {
|
|||||||
<MenuV2.RadioItem
|
<MenuV2.RadioItem
|
||||||
value={modelKey(item)}
|
value={modelKey(item)}
|
||||||
data-option-key={modelKey(item)}
|
data-option-key={modelKey(item)}
|
||||||
data-selected-model={props.current === modelKey(item) ? true : undefined}
|
data-selected-model={props.current() === modelKey(item) ? true : undefined}
|
||||||
class="scroll-my-6 w-full"
|
class="scroll-my-6 w-full"
|
||||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
@@ -529,7 +529,7 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
|
|||||||
|
|
||||||
const provider = () => {
|
const provider = () => {
|
||||||
void import("./dialog-connect-provider").then((x) => {
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
|
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
|
||||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
|
||||||
import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useMcpToggle } from "@/context/mcp"
|
|
||||||
import { useSDK } from "@/context/sdk"
|
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useSync } from "@/context/sync"
|
|
||||||
import { ExternalLink } from "./external-link"
|
|
||||||
|
|
||||||
type SkillItem = {
|
|
||||||
name: string
|
|
||||||
location: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const pluginName = (item: string | [string, Record<string, unknown>]) => (typeof item === "string" ? item : item[0])
|
|
||||||
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
|
|
||||||
|
|
||||||
const ExtensionCard: Component<{ children: unknown }> = (props) => (
|
|
||||||
<div class="project-settings-extension-card">{props.children as any}</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const ExtensionRow: Component<{
|
|
||||||
icon: "mcp" | "cube" | "post-skill" | "code"
|
|
||||||
name: string
|
|
||||||
status?: string
|
|
||||||
children?: unknown
|
|
||||||
}> = (props) => (
|
|
||||||
<div class="project-settings-extension-row">
|
|
||||||
<div class="project-settings-extension-row-main">
|
|
||||||
<Icon name={props.icon} class="project-settings-extension-row-icon" />
|
|
||||||
<span class="project-settings-extension-row-name">{props.name}</span>
|
|
||||||
</div>
|
|
||||||
<Show when={props.status}>
|
|
||||||
{(status) => (
|
|
||||||
<span class="project-settings-extension-row-status">
|
|
||||||
<span class="project-settings-extension-row-status-dot" />
|
|
||||||
{status()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
{props.children as any}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const SharedSection: Component<{
|
|
||||||
count: number
|
|
||||||
children: unknown
|
|
||||||
}> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const [open, setOpen] = createSignal(false)
|
|
||||||
return (
|
|
||||||
<Show when={props.count > 0}>
|
|
||||||
<div class="project-settings-shared">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="project-settings-shared-trigger"
|
|
||||||
aria-expanded={open()}
|
|
||||||
onClick={() => setOpen((value) => !value)}
|
|
||||||
>
|
|
||||||
<Icon name="chevron-right" classList={{ "project-settings-shared-chevron": true, open: open() }} />
|
|
||||||
<span>{language.t("project.settings.extensions.shared")}</span>
|
|
||||||
<span class="project-settings-shared-count">{props.count}</span>
|
|
||||||
</button>
|
|
||||||
<Show when={open()}>
|
|
||||||
<ExtensionCard>{props.children}</ExtensionCard>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProjectSettingsExtensions: Component = () => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const serverSDK = useServerSDK()
|
|
||||||
const directorySDK = useSDK()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const sync = useSync()
|
|
||||||
const toggleMcp = useMcpToggle()
|
|
||||||
|
|
||||||
const [serverMcp] = createResource(
|
|
||||||
serverSDK,
|
|
||||||
(sdk) =>
|
|
||||||
sdk.api.mcp
|
|
||||||
.list()
|
|
||||||
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
|
||||||
.catch(() => ({})),
|
|
||||||
{ initialValue: {} },
|
|
||||||
)
|
|
||||||
const globalMcpNames = createMemo(() =>
|
|
||||||
[...new Set([...Object.keys(serverSync().data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
|
|
||||||
)
|
|
||||||
const projectMcpNames = createMemo(() => {
|
|
||||||
const shared = new Set(globalMcpNames())
|
|
||||||
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
|
|
||||||
if (configured.length > 0) return configured.sort()
|
|
||||||
return Object.keys(sync().data.mcp ?? {})
|
|
||||||
.filter((name) => !shared.has(name))
|
|
||||||
.sort()
|
|
||||||
})
|
|
||||||
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
|
|
||||||
|
|
||||||
const globalPlugins = createMemo(() => (serverSync().data.config.plugin ?? []).map(pluginName))
|
|
||||||
const projectPlugins = createMemo(() => {
|
|
||||||
const shared = new Set(globalPlugins())
|
|
||||||
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
|
|
||||||
})
|
|
||||||
|
|
||||||
const [serverSkills] = createResource(
|
|
||||||
serverSDK,
|
|
||||||
(sdk): Promise<SkillItem[]> =>
|
|
||||||
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
|
||||||
{ initialValue: [] },
|
|
||||||
)
|
|
||||||
const [directorySkills] = createResource(
|
|
||||||
directorySDK,
|
|
||||||
(sdk): Promise<SkillItem[]> =>
|
|
||||||
sdk.api.skill
|
|
||||||
.list({ location: { directory: sdk.directory } })
|
|
||||||
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
|
||||||
{ initialValue: [] },
|
|
||||||
)
|
|
||||||
const projectSkills = createMemo(() => {
|
|
||||||
const shared = new Set(serverSkills.latest.map(skillKey))
|
|
||||||
return directorySkills.latest.filter((item) => !shared.has(skillKey(item)))
|
|
||||||
})
|
|
||||||
|
|
||||||
const mcpRows = (items: string[]) => (
|
|
||||||
<For each={items}>
|
|
||||||
{(name) => (
|
|
||||||
<ExtensionRow icon="mcp" name={name}>
|
|
||||||
<Switch
|
|
||||||
checked={mcpEnabled(name)}
|
|
||||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
|
||||||
hideLabel
|
|
||||||
onChange={() => {
|
|
||||||
if (toggleMcp.isPending) return
|
|
||||||
toggleMcp.mutate(name)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</Switch>
|
|
||||||
</ExtensionRow>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
)
|
|
||||||
|
|
||||||
const pluginRows = (items: string[]) => <For each={items}>{(name) => <ExtensionRow icon="cube" name={name} />}</For>
|
|
||||||
|
|
||||||
const skillRows = (items: SkillItem[]) => (
|
|
||||||
<For each={items}>{(item) => <ExtensionRow icon="post-skill" name={item.name} />}</For>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="project-settings-extensions">
|
|
||||||
<div class="project-settings-page-header">
|
|
||||||
<h2>{language.t("settings.tab.extensions")}</h2>
|
|
||||||
<span>{language.t("project.settings.extensions.description")}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabsV2 variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
|
|
||||||
<TabsV2.List>
|
|
||||||
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
|
|
||||||
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
|
|
||||||
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
|
|
||||||
<TabsV2.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</TabsV2.Trigger>
|
|
||||||
</TabsV2.List>
|
|
||||||
|
|
||||||
<TabsV2.Content value="mcps">
|
|
||||||
<div class="project-settings-extension-section">
|
|
||||||
<div class="project-settings-extension-section-header">
|
|
||||||
<span>{language.t("project.settings.extensions.added")}</span>
|
|
||||||
<span>{language.t("settings.extensions.manageConfig")}</span>
|
|
||||||
</div>
|
|
||||||
<Show when={projectMcpNames().length > 0}>
|
|
||||||
<ExtensionCard>{mcpRows(projectMcpNames())}</ExtensionCard>
|
|
||||||
</Show>
|
|
||||||
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
|
|
||||||
</div>
|
|
||||||
</TabsV2.Content>
|
|
||||||
|
|
||||||
<TabsV2.Content value="plugins">
|
|
||||||
<div class="project-settings-extension-section">
|
|
||||||
<div class="project-settings-extension-section-header">
|
|
||||||
<span>{language.t("project.settings.extensions.added")}</span>
|
|
||||||
<span>{language.t("settings.extensions.manageConfig")}</span>
|
|
||||||
</div>
|
|
||||||
<Show when={projectPlugins().length > 0}>
|
|
||||||
<ExtensionCard>{pluginRows(projectPlugins())}</ExtensionCard>
|
|
||||||
</Show>
|
|
||||||
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
|
|
||||||
</div>
|
|
||||||
</TabsV2.Content>
|
|
||||||
|
|
||||||
<TabsV2.Content value="skills">
|
|
||||||
<div class="project-settings-extension-section">
|
|
||||||
<div class="project-settings-extension-section-header">
|
|
||||||
<span>{language.t("project.settings.extensions.added")}</span>
|
|
||||||
<ExternalLink class="project-settings-extension-link" href="https://opencode.ai/docs/skills/">
|
|
||||||
{language.t("settings.extensions.addSkills")}
|
|
||||||
</ExternalLink>
|
|
||||||
</div>
|
|
||||||
<Show when={projectSkills().length > 0}>
|
|
||||||
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
|
|
||||||
</Show>
|
|
||||||
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection>
|
|
||||||
</div>
|
|
||||||
</TabsV2.Content>
|
|
||||||
|
|
||||||
<TabsV2.Content value="lsps">
|
|
||||||
<div class="project-settings-extension-section">
|
|
||||||
<div class="project-settings-extension-section-header">
|
|
||||||
<span>{language.t("project.settings.extensions.lsp.detected")}</span>
|
|
||||||
<span>{language.t("project.settings.extensions.lsp.description")}</span>
|
|
||||||
</div>
|
|
||||||
<Show when={sync().data.lsp.length > 0}>
|
|
||||||
<ExtensionCard>
|
|
||||||
<For each={sync().data.lsp}>
|
|
||||||
{(item) => (
|
|
||||||
<ExtensionRow
|
|
||||||
icon="code"
|
|
||||||
name={item.name || item.id}
|
|
||||||
status={
|
|
||||||
item.status === "error" ? language.t("project.settings.extensions.setupRequired") : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</ExtensionCard>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</TabsV2.Content>
|
|
||||||
</TabsV2>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -238,7 +238,6 @@ export function createProviderConnectionController(options: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
loading: () => integration.loading,
|
loading: () => integration.loading,
|
||||||
integration: () => integration.latest,
|
|
||||||
methods,
|
methods,
|
||||||
currentMethod,
|
currentMethod,
|
||||||
methodIndex: () => store.methodIndex,
|
methodIndex: () => store.methodIndex,
|
||||||
|
|||||||
@@ -96,16 +96,13 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
{(conn) => (
|
{(conn) => (
|
||||||
<div class="flex flex-row gap-3">
|
<div class="flex flex-row gap-3">
|
||||||
<span>
|
<span>
|
||||||
<Show
|
{conn().http.username ? (
|
||||||
when={conn().http.username}
|
|
||||||
fallback={<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>}
|
|
||||||
>
|
|
||||||
<span class="text-text-weak">{conn().http.username}</span>
|
<span class="text-text-weak">{conn().http.username}</span>
|
||||||
</Show>
|
) : (
|
||||||
|
<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<Show when={conn().http.password}>
|
{conn().http.password && <span class="text-text-weak">••••••••</span>}
|
||||||
<span class="text-text-weak">••••••••</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { FileVisual } from "./session-sortable-tab"
|
|||||||
|
|
||||||
export function SortableTabV2(props: {
|
export function SortableTabV2(props: {
|
||||||
tab: string
|
tab: string
|
||||||
index: number
|
index: () => number
|
||||||
temporary?: boolean
|
temporary?: boolean
|
||||||
onTabClose: (tab: string) => void
|
onTabClose: (tab: string) => void
|
||||||
onTabDoubleClick?: (tab: string) => void
|
onTabDoubleClick?: (tab: string) => void
|
||||||
@@ -26,7 +26,7 @@ export function SortableTabV2(props: {
|
|||||||
return props.tab
|
return props.tab
|
||||||
},
|
},
|
||||||
get index() {
|
get index() {
|
||||||
return props.index
|
return props.index()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { focusTerminalById } from "@/pages/session/helpers"
|
|||||||
|
|
||||||
export function SortableTerminalTabV2(props: {
|
export function SortableTerminalTabV2(props: {
|
||||||
terminal: LocalPTY
|
terminal: LocalPTY
|
||||||
index: number
|
index: () => number
|
||||||
newLayout: boolean
|
newLayout: boolean
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
@@ -25,7 +25,7 @@ export function SortableTerminalTabV2(props: {
|
|||||||
return props.terminal.id
|
return props.terminal.id
|
||||||
},
|
},
|
||||||
get index() {
|
get index() {
|
||||||
return props.index
|
return props.index()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js"
|
import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Dynamic } from "solid-js/web"
|
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
@@ -425,9 +424,9 @@ function SettingsKeybindsV2() {
|
|||||||
filtered={controller.catalog.filtered}
|
filtered={controller.catalog.filtered}
|
||||||
title={controller.catalog.title}
|
title={controller.catalog.title}
|
||||||
keybind={controller.catalog.keybind}
|
keybind={controller.catalog.keybind}
|
||||||
active={controller.capture.active()}
|
active={controller.capture.active}
|
||||||
onCapture={controller.capture.toggle}
|
onCapture={controller.capture.toggle}
|
||||||
hasOverrides={controller.settings.hasOverrides()}
|
hasOverrides={controller.settings.hasOverrides}
|
||||||
onReset={controller.settings.reset}
|
onReset={controller.settings.reset}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -438,9 +437,9 @@ function SettingsKeybindsV2View(props: {
|
|||||||
filtered: (query: string) => Map<KeybindGroup, string[]>
|
filtered: (query: string) => Map<KeybindGroup, string[]>
|
||||||
title: (id: string) => string
|
title: (id: string) => string
|
||||||
keybind: (id: string) => string
|
keybind: (id: string) => string
|
||||||
active: string | null
|
active: () => string | null
|
||||||
onCapture: (id: string) => void
|
onCapture: (id: string) => void
|
||||||
hasOverrides: boolean
|
hasOverrides: () => boolean
|
||||||
onReset: () => void
|
onReset: () => void
|
||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -452,11 +451,8 @@ function SettingsKeybindsV2View(props: {
|
|||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||||
<div class="settings-v2-tab-header-row">
|
<div class="settings-v2-tab-header-row">
|
||||||
<div class="flex flex-col gap-1">
|
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
|
||||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.shortcuts.description")}</span>
|
|
||||||
</div>
|
|
||||||
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides}>
|
|
||||||
{language.t("settings.shortcuts.reset.button")}
|
{language.t("settings.shortcuts.reset.button")}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</div>
|
</div>
|
||||||
@@ -502,12 +498,12 @@ function SettingsKeybindsV2View(props: {
|
|||||||
data-keybind-id={id}
|
data-keybind-id={id}
|
||||||
classList={{
|
classList={{
|
||||||
"settings-v2-keybind-button": true,
|
"settings-v2-keybind-button": true,
|
||||||
"settings-v2-keybind-button--active": props.active === id,
|
"settings-v2-keybind-button--active": props.active() === id,
|
||||||
}}
|
}}
|
||||||
onClick={() => props.onCapture(id)}
|
onClick={() => props.onCapture(id)}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={props.active === id}
|
when={props.active() === id}
|
||||||
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
||||||
>
|
>
|
||||||
{language.t("settings.shortcuts.pressKeys")}
|
{language.t("settings.shortcuts.pressKeys")}
|
||||||
@@ -678,6 +674,8 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const List = props.v2 ? SettingsListV2 : SettingsList
|
||||||
|
|
||||||
const groups = (
|
const groups = (
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
@@ -702,7 +700,7 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
|||||||
>
|
>
|
||||||
{language.t(groupKey[group])}
|
{language.t(groupKey[group])}
|
||||||
</h3>
|
</h3>
|
||||||
<Dynamic component={props.v2 ? SettingsListV2 : SettingsList}>
|
<List>
|
||||||
<For each={filtered().get(group) ?? []}>
|
<For each={filtered().get(group) ?? []}>
|
||||||
{(id) => (
|
{(id) => (
|
||||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||||
@@ -737,7 +735,7 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</Dynamic>
|
</List>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { type ParentProps, Show } from "solid-js"
|
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { ModelsProvider } from "@/context/models"
|
|
||||||
import { ServerConnection } from "@/context/server"
|
|
||||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
|
||||||
import { ServerSyncProvider } from "@/context/server-sync"
|
|
||||||
|
|
||||||
export function SettingsServerScope(props: ParentProps<{ directory?: string }>) {
|
|
||||||
const global = useGlobal()
|
|
||||||
return (
|
|
||||||
<Show when={global.settings.server.selected()} keyed fallback={props.children}>
|
|
||||||
{(server) => (
|
|
||||||
<SettingsServerDataScope server={server} directory={props.directory}>
|
|
||||||
{props.children}
|
|
||||||
</SettingsServerDataScope>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any; directory?: string }>) {
|
|
||||||
return (
|
|
||||||
<ServerSDKProvider server={props.server}>
|
|
||||||
<ServerSyncProvider server={props.server}>
|
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
|
||||||
</ServerSyncProvider>
|
|
||||||
</ServerSDKProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import { Component, createMemo } from "solid-js"
|
|
||||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { ExternalLink } from "../external-link"
|
|
||||||
import { SettingsListV2 } from "./parts/list"
|
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
|
||||||
import { createAppearanceSettingsController, type AppearanceSettingsController } from "./general-controllers"
|
|
||||||
import "./settings-v2.css"
|
|
||||||
|
|
||||||
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
|
||||||
const fontSettings = {
|
|
||||||
ui: {
|
|
||||||
action: "settings-ui-font",
|
|
||||||
title: "settings.general.row.uiFont.title",
|
|
||||||
description: "settings.general.row.uiFont.description",
|
|
||||||
font: "ui",
|
|
||||||
input: "setUI",
|
|
||||||
},
|
|
||||||
code: {
|
|
||||||
action: "settings-code-font",
|
|
||||||
title: "settings.general.row.font.title",
|
|
||||||
description: "settings.general.row.font.description",
|
|
||||||
font: "code",
|
|
||||||
input: "setCode",
|
|
||||||
},
|
|
||||||
terminal: {
|
|
||||||
action: "settings-terminal-font",
|
|
||||||
title: "settings.general.row.terminalFont.title",
|
|
||||||
description: "settings.general.row.terminalFont.description",
|
|
||||||
font: "terminal",
|
|
||||||
input: "setTerminal",
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const FontSetting: Component<{
|
|
||||||
kind: "ui" | "code" | "terminal"
|
|
||||||
fonts: AppearanceSettingsController["fonts"]
|
|
||||||
}> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const config = () => fontSettings[props.kind]
|
|
||||||
return (
|
|
||||||
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
|
||||||
<div class="w-full sm:w-[220px]">
|
|
||||||
<TextInputV2
|
|
||||||
data-action={config().action}
|
|
||||||
type="text"
|
|
||||||
appearance="base"
|
|
||||||
value={props.fonts[config().font]().value}
|
|
||||||
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
|
|
||||||
placeholder={props.fonts[config().font]().placeholder}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
aria-label={language.t(config().title)}
|
|
||||||
style={{ "font-family": props.fonts[config().font]().family }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsAppearanceV2: Component = () => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const appearance = createAppearanceSettingsController()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="settings-v2-tab-header">
|
|
||||||
<div class="settings-v2-tab-header-row">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.general.section.appearance")}</h2>
|
|
||||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.appearance.description")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<SettingsListV2>
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.colorScheme.title")}
|
|
||||||
description={language.t("settings.general.row.colorScheme.description")}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-color-scheme"
|
|
||||||
options={schemeOptions}
|
|
||||||
current={schemeOptions.find((option) => option === appearance.scheme.current())}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
label={(option) => {
|
|
||||||
if (option === "system") return language.t("theme.scheme.system")
|
|
||||||
if (option === "light") return language.t("theme.scheme.light")
|
|
||||||
return language.t("theme.scheme.dark")
|
|
||||||
}}
|
|
||||||
onSelect={(option) => option && appearance.scheme.select(option)}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.theme.title")}
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
{language.t("settings.general.row.theme.description")}{" "}
|
|
||||||
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
|
||||||
{language.t("common.learnMore")}
|
|
||||||
</ExternalLink>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-theme"
|
|
||||||
options={appearance.theme.options()}
|
|
||||||
current={appearance.theme.current()}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
value={(option) => option.id}
|
|
||||||
label={(option) => option.name}
|
|
||||||
onSelect={appearance.theme.select}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<FontSetting kind="ui" fonts={appearance.fonts} />
|
|
||||||
<FontSetting kind="code" fonts={appearance.fonts} />
|
|
||||||
<FontSetting kind="terminal" fonts={appearance.fonts} />
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -5,22 +5,15 @@ import { Icon } from "@opencode-ai/ui/icon"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { SettingsGeneralV2 } from "./general"
|
import { SettingsGeneralV2 } from "./general"
|
||||||
import { SettingsAppearanceV2 } from "./appearance"
|
|
||||||
import { SettingsKeybinds } from "../settings-keybinds"
|
import { SettingsKeybinds } from "../settings-keybinds"
|
||||||
import { SettingsNotificationsV2 } from "./notifications"
|
|
||||||
import { SettingsProvidersV2 } from "./providers"
|
import { SettingsProvidersV2 } from "./providers"
|
||||||
import { SettingsModelsV2 } from "./models"
|
import { SettingsModelsV2 } from "./models"
|
||||||
|
import "./settings-v2.css"
|
||||||
import { SettingsServersV2 } from "./servers"
|
import { SettingsServersV2 } from "./servers"
|
||||||
import { SettingsProjectsV2 } from "./projects"
|
|
||||||
import { SettingsExtensionsV2 } from "./extensions"
|
|
||||||
import { SettingsServerScope } from "../settings-server-picker"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { ServerConnection } from "@/context/server"
|
|
||||||
import "./settings-v2.css"
|
|
||||||
|
|
||||||
export const DialogSettings: Component<{
|
export const DialogSettings: Component<{
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -32,13 +25,8 @@ export const DialogSettings: Component<{
|
|||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const global = useGlobal()
|
|
||||||
const currentServer = global.servers.list().find((server) => global.ensureServerCtx(server).sync === serverSync())
|
|
||||||
if (currentServer) global.settings.server.set(ServerConnection.key(currentServer))
|
|
||||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||||
const directory = createMemo(() => {
|
const directory = createMemo(() => {
|
||||||
const server = global.settings.server.selected()
|
|
||||||
if (!server || serverSync() !== global.ensureServerCtx(server).sync) return
|
|
||||||
const route = layout.route()
|
const route = layout.route()
|
||||||
if (route.type === "dir-new-sesssion") return route.dir
|
if (route.type === "dir-new-sesssion") return route.dir
|
||||||
if (route.type === "draft") {
|
if (route.type === "draft") {
|
||||||
@@ -64,92 +52,62 @@ export const DialogSettings: Component<{
|
|||||||
>
|
>
|
||||||
<TabsV2.List>
|
<TabsV2.List>
|
||||||
<div class="flex flex-col justify-between h-full w-full">
|
<div class="flex flex-col justify-between h-full w-full">
|
||||||
<div class="flex flex-col gap-4 w-full">
|
<div class="flex flex-col gap-3 w-full">
|
||||||
{/* Group 1: Preferences */}
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex flex-col gap-1 w-full">
|
<div class="flex flex-col gap-1.5">
|
||||||
<TabsV2.Trigger value="general">
|
<TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
|
||||||
<Icon name="sliders" />
|
<div class="flex flex-col gap-1.5 w-full">
|
||||||
{language.t("settings.tab.preferences")}
|
<TabsV2.Trigger value="general">
|
||||||
</TabsV2.Trigger>
|
<Icon name="sliders" />
|
||||||
<TabsV2.Trigger value="appearance">
|
{language.t("settings.tab.general")}
|
||||||
<Icon name="appearance" />
|
</TabsV2.Trigger>
|
||||||
{language.t("settings.general.section.appearance")}
|
<TabsV2.Trigger value="shortcuts">
|
||||||
</TabsV2.Trigger>
|
<Icon name="keyboard" />
|
||||||
<TabsV2.Trigger value="notifications">
|
{language.t("settings.tab.shortcuts")}
|
||||||
<Icon name="notifications" />
|
</TabsV2.Trigger>
|
||||||
{language.t("settings.tab.notifications")}
|
</div>
|
||||||
</TabsV2.Trigger>
|
</div>
|
||||||
<TabsV2.Trigger value="shortcuts">
|
|
||||||
<Icon name="keyboard" />
|
|
||||||
{language.t("settings.tab.shortcuts")}
|
|
||||||
</TabsV2.Trigger>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Group 2: Environment & Workspaces */}
|
<div class="flex flex-col gap-1.5">
|
||||||
<div class="flex flex-col gap-1 w-full">
|
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
|
||||||
<TabsV2.Trigger value="servers">
|
<div class="flex flex-col gap-1.5 w-full">
|
||||||
<Icon name="server" />
|
<TabsV2.Trigger value="servers">
|
||||||
{language.t("status.popover.tab.servers")}
|
<Icon name="server" />
|
||||||
</TabsV2.Trigger>
|
{language.t("status.popover.tab.servers")}
|
||||||
<TabsV2.Trigger value="projects">
|
</TabsV2.Trigger>
|
||||||
<Icon name="folder" />
|
<TabsV2.Trigger value="providers">
|
||||||
{language.t("settings.tab.projects")}
|
<Icon name="providers" />
|
||||||
</TabsV2.Trigger>
|
{language.t("settings.providers.title")}
|
||||||
</div>
|
</TabsV2.Trigger>
|
||||||
|
<TabsV2.Trigger value="models">
|
||||||
{/* Group 3: Capabilities & Extensions */}
|
<Icon name="models" />
|
||||||
<div class="flex flex-col gap-1 w-full">
|
{language.t("settings.models.title")}
|
||||||
<TabsV2.Trigger value="providers">
|
</TabsV2.Trigger>
|
||||||
<Icon name="providers" />
|
</div>
|
||||||
{language.t("settings.providers.title")}
|
</div>
|
||||||
</TabsV2.Trigger>
|
|
||||||
<TabsV2.Trigger value="models">
|
|
||||||
<Icon name="models" />
|
|
||||||
{language.t("settings.models.title")}
|
|
||||||
</TabsV2.Trigger>
|
|
||||||
<TabsV2.Trigger value="extensions">
|
|
||||||
<Icon name="extensions" />
|
|
||||||
{language.t("settings.tab.extensions")}
|
|
||||||
</TabsV2.Trigger>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-nav-footer">
|
<div class="settings-v2-nav-footer">
|
||||||
<span>{language.t("app.name.desktop")}</span>
|
<span>{language.t("app.name.desktop")}</span>
|
||||||
<span>v{platform.version}</span>
|
<span>v{platform.version}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsV2.List>
|
</TabsV2.List>
|
||||||
|
|
||||||
<TabsV2.Content value="general" class="settings-v2-panel">
|
<TabsV2.Content value="general" class="settings-v2-panel">
|
||||||
<SettingsGeneralV2 sessionID={props.sessionID} />
|
<SettingsGeneralV2 sessionID={props.sessionID} />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="appearance" class="settings-v2-panel">
|
|
||||||
<SettingsAppearanceV2 />
|
|
||||||
</TabsV2.Content>
|
|
||||||
<TabsV2.Content value="notifications" class="settings-v2-panel">
|
|
||||||
<SettingsNotificationsV2 />
|
|
||||||
</TabsV2.Content>
|
|
||||||
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
||||||
<SettingsKeybinds v2 />
|
<SettingsKeybinds v2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="servers" class="settings-v2-panel">
|
<TabsV2.Content value="servers" class="settings-v2-panel">
|
||||||
<SettingsServersV2 />
|
<SettingsServersV2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="projects" class="settings-v2-panel">
|
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||||
<SettingsProjectsV2 />
|
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
|
||||||
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="models" class="settings-v2-panel">
|
||||||
|
<SettingsModelsV2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<SettingsServerScope directory={directory()}>
|
|
||||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
|
||||||
<SettingsProvidersV2 directory={directory()} onBack={showProviders} />
|
|
||||||
</TabsV2.Content>
|
|
||||||
<TabsV2.Content value="models" class="settings-v2-panel">
|
|
||||||
<SettingsModelsV2 />
|
|
||||||
</TabsV2.Content>
|
|
||||||
<TabsV2.Content value="extensions" class="settings-v2-panel">
|
|
||||||
<SettingsExtensionsV2 />
|
|
||||||
</TabsV2.Content>
|
|
||||||
</SettingsServerScope>
|
|
||||||
</TabsV2>
|
</TabsV2>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
import { Component, For, createMemo, createResource } from "solid-js"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
|
||||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { ExternalLink } from "../external-link"
|
|
||||||
import { InlineServerSelect } from "./parts/server-select"
|
|
||||||
import "./settings-v2.css"
|
|
||||||
|
|
||||||
interface McpRowItem {
|
|
||||||
name: string
|
|
||||||
enabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PluginRowItem {
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsExtensionsV2: Component = () => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const serverSdk = useServerSDK()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const mcps = createMemo<McpRowItem[]>(() => {
|
|
||||||
const configMcp = serverSync().data.config.mcp ?? {}
|
|
||||||
return Object.entries(configMcp).map(([name, config]) => ({
|
|
||||||
name,
|
|
||||||
enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
|
|
||||||
const before = serverSync().data.config.mcp ?? {}
|
|
||||||
const config = before[item.name]
|
|
||||||
if (typeof config !== "object" || config === null) return
|
|
||||||
const next = { ...before, [item.name]: { ...config, enabled: checked } }
|
|
||||||
serverSync().set("config", "mcp", next)
|
|
||||||
void serverSync()
|
|
||||||
.updateConfig({ mcp: next })
|
|
||||||
.catch(() => serverSync().set("config", "mcp", before))
|
|
||||||
}
|
|
||||||
|
|
||||||
const plugins = createMemo<PluginRowItem[]>(() => {
|
|
||||||
const raw = serverSync().data.config.plugin ?? []
|
|
||||||
return raw.map((item) => {
|
|
||||||
const name = typeof item === "string" ? item : item[0]
|
|
||||||
return { name }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), {
|
|
||||||
initialValue: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="settings-v2-tab-header">
|
|
||||||
<div class="settings-v2-tab-header-row">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.extensions")}</h2>
|
|
||||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.extensions.description")}</span>
|
|
||||||
</div>
|
|
||||||
<InlineServerSelect />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
|
||||||
<TabsV2 variant="pill" defaultValue="mcps" class="settings-v2-extensions-tabs">
|
|
||||||
<TabsV2.List>
|
|
||||||
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
|
|
||||||
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
|
|
||||||
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
|
|
||||||
</TabsV2.List>
|
|
||||||
|
|
||||||
<TabsV2.Content value="mcps">
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-13-medium text-v2-text-text-base">
|
|
||||||
{language.t("settings.extensions.availableAll")}
|
|
||||||
</span>
|
|
||||||
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
|
||||||
</div>
|
|
||||||
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
|
||||||
<For each={mcps()}>
|
|
||||||
{(item) => (
|
|
||||||
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
|
||||||
<div class="flex items-center gap-2.5 min-w-0">
|
|
||||||
<Icon name="mcp" class="text-v2-icon-icon-muted shrink-0" />
|
|
||||||
<span class="text-13-medium text-v2-text-text-base truncate">{item.name}</span>
|
|
||||||
</div>
|
|
||||||
<Switch checked={item.enabled} onChange={(checked) => handleMcpToggle(item, checked)} hideLabel>
|
|
||||||
{item.name}
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsV2.Content>
|
|
||||||
|
|
||||||
<TabsV2.Content value="plugins">
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-13-medium text-v2-text-text-base">
|
|
||||||
{language.t("settings.extensions.availableAll")}
|
|
||||||
</span>
|
|
||||||
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
|
||||||
</div>
|
|
||||||
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
|
||||||
<For each={plugins()}>
|
|
||||||
{(plugin) => (
|
|
||||||
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
|
||||||
<div class="flex items-center gap-2.5 min-w-0">
|
|
||||||
<Icon name="cube" class="text-v2-icon-icon-muted shrink-0" />
|
|
||||||
<span class="text-13-medium text-v2-text-text-base truncate font-mono">{plugin.name}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsV2.Content>
|
|
||||||
|
|
||||||
<TabsV2.Content value="skills">
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-13-medium text-v2-text-text-base">
|
|
||||||
{language.t("settings.extensions.availableAll")}
|
|
||||||
</span>
|
|
||||||
<ExternalLink
|
|
||||||
class="text-13-regular text-v2-text-accent hover:underline"
|
|
||||||
href="https://opencode.ai/docs/skills/"
|
|
||||||
>
|
|
||||||
{language.t("settings.extensions.addSkills")}
|
|
||||||
</ExternalLink>
|
|
||||||
</div>
|
|
||||||
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
|
||||||
<For each={skills()}>
|
|
||||||
{(skill) => (
|
|
||||||
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
|
||||||
<div class="flex items-center gap-2.5 min-w-0">
|
|
||||||
<Icon name="post-skill" class="text-v2-icon-icon-muted shrink-0" />
|
|
||||||
<span class="text-13-medium text-v2-text-text-base truncate">{skill.name}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsV2.Content>
|
|
||||||
</TabsV2>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -279,6 +279,8 @@ export const SettingsGeneralV2: Component<{
|
|||||||
const updater = useUpdaterAction()
|
const updater = useUpdaterAction()
|
||||||
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
||||||
const shell = createShellSettingsController()
|
const shell = createShellSettingsController()
|
||||||
|
const appearance = createAppearanceSettingsController()
|
||||||
|
const sounds = createSoundSettingsController()
|
||||||
const desktop = createMemo(() => platform.platform === "desktop")
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||||
@@ -296,7 +298,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
|
|
||||||
const GeneralSection = () => (
|
const GeneralSection = () => (
|
||||||
<div class="settings-v2-section">
|
<div class="settings-v2-section">
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.general")}</h3>
|
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<LanguageSetting />
|
<LanguageSetting />
|
||||||
|
|
||||||
@@ -509,19 +510,18 @@ export const SettingsGeneralV2: Component<{
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header">
|
<div class="settings-v2-tab-header">
|
||||||
<div class="settings-v2-tab-header-row">
|
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.preferences")}</h2>
|
|
||||||
<span class="text-11-regular text-v2-text-text-muted">
|
|
||||||
{language.t("settings.preferences.description")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
<div class="settings-v2-tab-body">
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
|
|
||||||
|
<AppearanceSection controller={appearance} />
|
||||||
|
|
||||||
|
<NotificationsSection />
|
||||||
|
|
||||||
|
<SoundsSection controller={sounds} />
|
||||||
|
|
||||||
<Show when={desktop()}>
|
<Show when={desktop()}>
|
||||||
<UpdatesSection />
|
<UpdatesSection />
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { useModels } from "@/context/models"
|
|||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { InlineServerSelect } from "./parts/server-select"
|
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
@@ -54,13 +53,7 @@ export const SettingsModelsV2: Component = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||||
<div class="settings-v2-tab-header-row">
|
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
|
|
||||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.models.description")}</span>
|
|
||||||
</div>
|
|
||||||
<InlineServerSelect />
|
|
||||||
</div>
|
|
||||||
<div class="settings-v2-tab-search">
|
<div class="settings-v2-tab-search">
|
||||||
<TextInputV2
|
<TextInputV2
|
||||||
type="search"
|
type="search"
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import { Component } from "solid-js"
|
|
||||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useSettings } from "@/context/settings"
|
|
||||||
import { SettingsListV2 } from "./parts/list"
|
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
|
||||||
import { createSoundSettingsController, soundOptions, type SoundSettingsController } from "./general-controllers"
|
|
||||||
import "./settings-v2.css"
|
|
||||||
|
|
||||||
const soundSettings = {
|
|
||||||
agent: {
|
|
||||||
action: "settings-sounds-agent",
|
|
||||||
title: "settings.general.sounds.agent.title",
|
|
||||||
description: "settings.general.sounds.agent.description",
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
action: "settings-sounds-permissions",
|
|
||||||
title: "settings.general.sounds.permissions.title",
|
|
||||||
description: "settings.general.sounds.permissions.description",
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
action: "settings-sounds-errors",
|
|
||||||
title: "settings.general.sounds.errors.title",
|
|
||||||
description: "settings.general.sounds.errors.description",
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const SoundSetting: Component<{
|
|
||||||
kind: "agent" | "permissions" | "errors"
|
|
||||||
channel: SoundSettingsController["agent"]
|
|
||||||
}> = (props) => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const config = () => soundSettings[props.kind]
|
|
||||||
return (
|
|
||||||
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action={config().action}
|
|
||||||
options={soundOptions}
|
|
||||||
current={props.channel.current()}
|
|
||||||
value={(option) => option.id}
|
|
||||||
label={(option) => language.t(option.label)}
|
|
||||||
onHighlight={props.channel.highlight}
|
|
||||||
onSelect={props.channel.select}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
/>
|
|
||||||
</SettingsRowV2>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsNotificationsV2: Component = () => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const settings = useSettings()
|
|
||||||
const sounds = createSoundSettingsController()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="settings-v2-tab-header">
|
|
||||||
<div class="settings-v2-tab-header-row">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.notifications")}</h2>
|
|
||||||
<span class="text-11-regular text-v2-text-text-muted">
|
|
||||||
{language.t("settings.notifications.description")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
|
|
||||||
<SettingsListV2>
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.notifications.agent.title")}
|
|
||||||
description={language.t("settings.general.notifications.agent.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-agent">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.agent()}
|
|
||||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.notifications.permissions.title")}
|
|
||||||
description={language.t("settings.general.notifications.permissions.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-permissions">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.permissions()}
|
|
||||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.notifications.errors.title")}
|
|
||||||
description={language.t("settings.general.notifications.errors.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-errors">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.errors()}
|
|
||||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
|
||||||
<SettingsListV2>
|
|
||||||
<SoundSetting kind="agent" channel={sounds.agent} />
|
|
||||||
<SoundSetting kind="permissions" channel={sounds.permissions} />
|
|
||||||
<SoundSetting kind="errors" channel={sounds.errors} />
|
|
||||||
</SettingsListV2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { Show, createMemo, type Component } from "solid-js"
|
|
||||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { ServerConnection, serverName } from "@/context/server"
|
|
||||||
|
|
||||||
const allServers = { type: "all" } as const
|
|
||||||
type ServerOption = ServerConnection.Any | typeof allServers
|
|
||||||
|
|
||||||
export const InlineServerSelect: Component<{
|
|
||||||
all?: {
|
|
||||||
label: string
|
|
||||||
selected: () => boolean
|
|
||||||
onSelect: () => void
|
|
||||||
}
|
|
||||||
onServerSelect?: () => void
|
|
||||||
}> = (props) => {
|
|
||||||
const global = useGlobal()
|
|
||||||
const options = createMemo<ServerOption[]>(() => [...(props.all ? [allServers] : []), ...global.servers.list()])
|
|
||||||
const current = () => (props.all?.selected() ? allServers : global.settings.server.selected())
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={options().length > 1}>
|
|
||||||
<SelectV2
|
|
||||||
appearance="inline"
|
|
||||||
data-action="settings-server-select"
|
|
||||||
options={options()}
|
|
||||||
current={current()}
|
|
||||||
value={(server) => (server.type === "all" ? server.type : ServerConnection.key(server))}
|
|
||||||
label={(server) =>
|
|
||||||
server.type === "all" ? (props.all?.label ?? "") : serverName(server) || ServerConnection.key(server)
|
|
||||||
}
|
|
||||||
optionDisabled={(server) =>
|
|
||||||
server.type === "all" ? false : global.servers.health[ServerConnection.key(server)]?.healthy === false
|
|
||||||
}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={6}
|
|
||||||
onSelect={(server) => {
|
|
||||||
if (!server) return
|
|
||||||
if (server.type === "all") {
|
|
||||||
props.all?.onSelect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
global.settings.server.set(ServerConnection.key(server))
|
|
||||||
props.onServerSelect?.()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|
||||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { getProjectAvatarVariant } from "@/context/layout"
|
|
||||||
import { ServerConnection, serverName } from "@/context/server"
|
|
||||||
import { displayName } from "@/pages/layout/helpers"
|
|
||||||
import { InlineServerSelect } from "./parts/server-select"
|
|
||||||
import { DialogEditProjectV2 } from "../dialog-edit-project-v2"
|
|
||||||
import "./settings-v2.css"
|
|
||||||
|
|
||||||
export const SettingsProjectsV2: Component = () => {
|
|
||||||
const dialog = useDialog()
|
|
||||||
const language = useLanguage()
|
|
||||||
const global = useGlobal()
|
|
||||||
const [allServers, setAllServers] = createSignal(true)
|
|
||||||
const selected = global.settings.server.selected
|
|
||||||
const projects = createMemo(() => {
|
|
||||||
const server = selected()
|
|
||||||
if (!server) return []
|
|
||||||
return global.ensureServerCtx(server).projects.list()
|
|
||||||
})
|
|
||||||
|
|
||||||
type ProjectItem = ReturnType<typeof projects>[number]
|
|
||||||
|
|
||||||
const groups = createMemo(() =>
|
|
||||||
global.servers
|
|
||||||
.list()
|
|
||||||
.map((server) => ({ server, projects: global.ensureServerCtx(server).projects.list() }))
|
|
||||||
.filter((group) => group.projects.length > 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
const openProjectSettings = (project: ProjectItem, server = selected()) => {
|
|
||||||
if (!server) return
|
|
||||||
dialog.push(() => <DialogEditProjectV2 project={project} server={server} />)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProjectRow: Component<{ project: ProjectItem; server: ServerConnection.Any }> = (props) => {
|
|
||||||
const name = () => displayName(props.project)
|
|
||||||
const color = () => getProjectAvatarVariant(props.project.icon?.color)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] cursor-pointer transition-all hover:bg-v2-background-bg-layer-01"
|
|
||||||
onClick={() => openProjectSettings(props.project, props.server)}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
|
||||||
<ProjectAvatar fallback={name()} variant={color()} class="shrink-0" />
|
|
||||||
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<IconButtonV2
|
|
||||||
type="button"
|
|
||||||
variant="ghost-muted"
|
|
||||||
size="small"
|
|
||||||
icon={<IconV2 name="settings-gear" size="small" class="text-v2-icon-icon-muted" />}
|
|
||||||
onClick={(event: MouseEvent) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
openProjectSettings(props.project, props.server)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="settings-v2-tab-header">
|
|
||||||
<div class="settings-v2-tab-header-row">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.projects.title")}</h2>
|
|
||||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
|
|
||||||
</div>
|
|
||||||
<InlineServerSelect
|
|
||||||
all={{
|
|
||||||
label: language.t("settings.projects.server.all"),
|
|
||||||
selected: allServers,
|
|
||||||
onSelect: () => setAllServers(true),
|
|
||||||
}}
|
|
||||||
onServerSelect={() => setAllServers(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
|
||||||
<Show
|
|
||||||
when={allServers()}
|
|
||||||
fallback={
|
|
||||||
<div class="flex flex-col gap-2 w-full">
|
|
||||||
<Show
|
|
||||||
when={projects().length > 0}
|
|
||||||
fallback={
|
|
||||||
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
|
||||||
{language.t("settings.projects.empty")}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show when={selected()} keyed>
|
|
||||||
{(server) => (
|
|
||||||
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-8 w-full">
|
|
||||||
<Show
|
|
||||||
when={groups().length > 0}
|
|
||||||
fallback={
|
|
||||||
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
|
||||||
{language.t("settings.projects.empty")}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={groups()}>
|
|
||||||
{(group) => (
|
|
||||||
<div class="settings-v2-section">
|
|
||||||
<h3 class="settings-v2-section-title">
|
|
||||||
{serverName(group.server) || ServerConnection.key(group.server)}
|
|
||||||
</h3>
|
|
||||||
<div class="flex flex-col gap-2 w-full">
|
|
||||||
<For each={group.projects}>
|
|
||||||
{(project) => <ProjectRow project={project} server={group.server} />}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,12 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { createMemo, type Component, For, Show } from "solid-js"
|
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||||
import { SettingsServerScope } from "../settings-server-picker"
|
|
||||||
import { InlineServerSelect } from "./parts/server-select"
|
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
@@ -32,35 +30,25 @@ const PROVIDER_NOTES = [
|
|||||||
const PROVIDER_ICON_SIZE = 16
|
const PROVIDER_ICON_SIZE = 16
|
||||||
|
|
||||||
export const SettingsProvidersV2: Component<{
|
export const SettingsProvidersV2: Component<{
|
||||||
directory: string | undefined
|
directory: Accessor<string | undefined>
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const providers = useProviders(() => props.directory)
|
const providers = useProviders(props.directory)
|
||||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||||
|
|
||||||
const connect = (provider?: string) => {
|
const connect = (provider?: string) => {
|
||||||
providerConnect.select(provider)
|
providerConnect.select(provider)
|
||||||
void dialog.show(() => (
|
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
|
||||||
<SettingsServerScope directory={props.directory}>
|
|
||||||
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
|
|
||||||
</SettingsServerScope>
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const connected = createMemo(() => {
|
const connected = createMemo(() => {
|
||||||
return providers.connected().filter(
|
return providers
|
||||||
(provider) =>
|
.connected()
|
||||||
provider.id !== "opencode" ||
|
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
|
||||||
Object.values(provider.models).some((model) => {
|
|
||||||
if (typeof model !== "object" || model === null || !("cost" in model)) return false
|
|
||||||
const cost = model.cost
|
|
||||||
return typeof cost === "object" && cost !== null && "input" in cost
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const popular = createMemo(() => {
|
const popular = createMemo(() => {
|
||||||
@@ -104,8 +92,31 @@ export const SettingsProvidersV2: Component<{
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
|
return
|
||||||
|
const before = serverSync().data.config.disabled_providers ?? []
|
||||||
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
|
serverSync().set("config", "disabled_providers", next)
|
||||||
|
|
||||||
|
await serverSync()
|
||||||
|
.updateConfig({ disabled_providers: next })
|
||||||
|
.then(() => {
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||||
|
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
serverSync().set("config", "disabled_providers", before)
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const disconnect = async (providerID: string, name: string) => {
|
const disconnect = async (providerID: string, name: string) => {
|
||||||
const location = props.directory ? { directory: props.directory } : undefined
|
const location = props.directory() ? { directory: props.directory() } : undefined
|
||||||
await serverSdk()
|
await serverSdk()
|
||||||
.api.integration.get({ integrationID: providerID, location })
|
.api.integration.get({ integrationID: providerID, location })
|
||||||
.then(async (integration) => {
|
.then(async (integration) => {
|
||||||
@@ -130,13 +141,7 @@ export const SettingsProvidersV2: Component<{
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header">
|
<div class="settings-v2-tab-header">
|
||||||
<div class="settings-v2-tab-header-row">
|
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
|
|
||||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.providers.description")}</span>
|
|
||||||
</div>
|
|
||||||
<InlineServerSelect />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-tab-body settings-v2-providers">
|
<div class="settings-v2-tab-body settings-v2-providers">
|
||||||
@@ -239,11 +244,7 @@ export const SettingsProvidersV2: Component<{
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
icon="plus"
|
icon="plus"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
dialog.show(() => (
|
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||||
<SettingsServerScope directory={props.directory}>
|
|
||||||
<DialogCustomProvider onBack={dialog.close} />
|
|
||||||
</SettingsServerScope>
|
|
||||||
))
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{language.t("common.connect")}
|
{language.t("common.connect")}
|
||||||
|
|||||||
@@ -53,10 +53,7 @@ export const SettingsServersV2: Component = () => {
|
|||||||
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
|
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
|
||||||
>
|
>
|
||||||
<div class="settings-v2-tab-header-row">
|
<div class="settings-v2-tab-header-row">
|
||||||
<div class="flex flex-col gap-1">
|
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
|
||||||
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
|
|
||||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.servers.description")}</span>
|
|
||||||
</div>
|
|
||||||
<AddServerMenu onAddServer={openAdd} />
|
<AddServerMenu onAddServer={openAdd} />
|
||||||
</div>
|
</div>
|
||||||
<Show when={showSearch()}>
|
<Show when={showSearch()}>
|
||||||
|
|||||||
@@ -39,14 +39,6 @@
|
|||||||
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
|
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-tab-header-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-tab-title {
|
.settings-v2-tab-title {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 640;
|
font-weight: 640;
|
||||||
@@ -182,13 +174,13 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] > [data-slot="tabs-v2-list"] {
|
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-list"] {
|
||||||
background-color: var(--v2-background-bg-layer-01);
|
background-color: var(--v2-background-bg-layer-01);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
@media (max-width: 639px) {
|
||||||
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
||||||
> [data-slot="tabs-v2-list"] {
|
[data-slot="tabs-v2-list"] {
|
||||||
width: 144px;
|
width: 144px;
|
||||||
min-width: 144px;
|
min-width: 144px;
|
||||||
padding-inline: 8px;
|
padding-inline: 8px;
|
||||||
@@ -735,24 +727,3 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--v2-state-fg-danger);
|
color: var(--v2-state-fg-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
|
|
||||||
width: 280px;
|
|
||||||
padding-inline: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
|
|
||||||
> [data-slot="tabs-v2-list"]
|
|
||||||
[data-slot="tabs-v2-trigger-wrapper"] {
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
|
|
||||||
> [data-slot="tabs-v2-list"]
|
|
||||||
[data-slot="tabs-v2-trigger"] {
|
|
||||||
padding-inline: 8px;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
|
import {
|
||||||
|
type Accessor,
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createResource,
|
||||||
|
For,
|
||||||
|
type JSXElement,
|
||||||
|
onCleanup,
|
||||||
|
Show,
|
||||||
|
} from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
@@ -100,7 +109,7 @@ type ServerStatusItem = {
|
|||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusPopoverBody(props: { shown: boolean }) {
|
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
@@ -116,6 +125,10 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!props.shown()) return
|
||||||
|
})
|
||||||
|
|
||||||
let dialogRun = 0
|
let dialogRun = 0
|
||||||
let dialogDead = false
|
let dialogDead = false
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -134,7 +147,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
|||||||
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
||||||
const lspCount = createMemo(() => lspItems().length)
|
const lspCount = createMemo(() => lspItems().length)
|
||||||
const [pluginList] = createResource(
|
const [pluginList] = createResource(
|
||||||
() => (props.shown ? sdk().directory : undefined),
|
() => (props.shown() ? sdk().directory : undefined),
|
||||||
(directory) =>
|
(directory) =>
|
||||||
sdk()
|
sdk()
|
||||||
.api.plugin.list({ location: { directory } })
|
.api.plugin.list({ location: { directory } })
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function StatusPopover() {
|
|||||||
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
|
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Body shown={shown()} />
|
<Body shown={shown} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Show>
|
</Show>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -114,7 +114,7 @@ function DirectoryStatusPopover() {
|
|||||||
onOpenChange: setShown,
|
onOpenChange: setShown,
|
||||||
body: () => (
|
body: () => (
|
||||||
<StatusPopoverBody shown={shown()}>
|
<StatusPopoverBody shown={shown()}>
|
||||||
<Body shown={shown()} />
|
<Body shown={shown} />
|
||||||
</StatusPopoverBody>
|
</StatusPopoverBody>
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -175,7 +175,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
// Intentional mount-time capture: the imperative xterm/WebSocket lifecycle needs stable values, and Terminal remounts when the SDK scope changes.
|
|
||||||
const directory = sdk().directory
|
const directory = sdk().directory
|
||||||
const url = sdk().url
|
const url = sdk().url
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ export function TabNavItem(props: {
|
|||||||
ref?: Ref<HTMLDivElement>
|
ref?: Ref<HTMLDivElement>
|
||||||
href: string
|
href: string
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
session: SessionInfo | undefined
|
session: () => SessionInfo | undefined
|
||||||
fallbackTitle?: string
|
fallbackTitle?: string
|
||||||
onRename: (title: string) => Promise<void>
|
onRename: (title: string) => Promise<void>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onNavigate: () => void
|
onNavigate: () => void
|
||||||
active?: boolean
|
active?: boolean
|
||||||
forceTruncate?: boolean
|
forceTruncate?: boolean
|
||||||
suppressNavigation?: boolean
|
suppressNavigation?: () => boolean
|
||||||
dragging?: boolean
|
dragging?: boolean
|
||||||
pressed?: boolean
|
pressed?: boolean
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
@@ -52,22 +52,22 @@ export function TabNavItem(props: {
|
|||||||
if (conn) return global.ensureServerCtx(conn)
|
if (conn) return global.ensureServerCtx(conn)
|
||||||
})
|
})
|
||||||
const project = createMemo(() => {
|
const project = createMemo(() => {
|
||||||
const session = props.session
|
const session = props.session()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||||
})
|
})
|
||||||
const title = createMemo(() => {
|
const title = createMemo(() => {
|
||||||
const session = props.session
|
const session = props.session()
|
||||||
return session ? sessionLabel(session) : props.fallbackTitle
|
return session ? sessionLabel(session) : props.fallbackTitle
|
||||||
})
|
})
|
||||||
|
|
||||||
const projectName = createMemo(() => {
|
const projectName = createMemo(() => {
|
||||||
const session = props.session
|
const session = props.session()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
return displayName(project() ?? { worktree: session.location.directory })
|
return displayName(project() ?? { worktree: session.location.directory })
|
||||||
})
|
})
|
||||||
const previewPath = createMemo(() => {
|
const previewPath = createMemo(() => {
|
||||||
const session = props.session
|
const session = props.session()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
const home = serverCtx()?.sync.data.path.home
|
const home = serverCtx()?.sync.data.path.home
|
||||||
return home ? session.location.directory.replace(home, "~") : session.location.directory
|
return home ? session.location.directory.replace(home, "~") : session.location.directory
|
||||||
@@ -80,7 +80,7 @@ export function TabNavItem(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [popoverOpen, setPopoverOpen] = createSignal(false)
|
const [popoverOpen, setPopoverOpen] = createSignal(false)
|
||||||
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session
|
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session()
|
||||||
|
|
||||||
const measureTitleOverflow = () => {
|
const measureTitleOverflow = () => {
|
||||||
if (!titleEl || editing()) {
|
if (!titleEl || editing()) {
|
||||||
@@ -121,7 +121,7 @@ export function TabNavItem(props: {
|
|||||||
const closeRename = async (save: boolean) => {
|
const closeRename = async (save: boolean) => {
|
||||||
if (rename.isPending || !editing()) return
|
if (rename.isPending || !editing()) return
|
||||||
|
|
||||||
const original = props.session?.title ?? ""
|
const original = props.session()?.title ?? ""
|
||||||
const next = (titleEl.textContent ?? "").trim()
|
const next = (titleEl.textContent ?? "").trim()
|
||||||
|
|
||||||
titleEl.scrollLeft = 0
|
titleEl.scrollLeft = 0
|
||||||
@@ -146,7 +146,7 @@ export function TabNavItem(props: {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
|
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
|
||||||
const session = props.session
|
const session = props.session()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
titleEl.textContent = session.title ?? ""
|
titleEl.textContent = session.title ?? ""
|
||||||
setEditing(true)
|
setEditing(true)
|
||||||
@@ -213,7 +213,7 @@ export function TabNavItem(props: {
|
|||||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||||
if (event.button !== 0) return
|
if (event.button !== 0) return
|
||||||
if (editing()) return
|
if (editing()) return
|
||||||
if (props.suppressNavigation) return
|
if (props.suppressNavigation?.()) return
|
||||||
props.onNavigate()
|
props.onNavigate()
|
||||||
}}
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
@@ -221,14 +221,14 @@ export function TabNavItem(props: {
|
|||||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||||
if (event.detail > 0) return
|
if (event.detail > 0) return
|
||||||
if (editing()) return
|
if (editing()) return
|
||||||
if (props.suppressNavigation) return
|
if (props.suppressNavigation?.()) return
|
||||||
props.onNavigate()
|
props.onNavigate()
|
||||||
}}
|
}}
|
||||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||||
>
|
>
|
||||||
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
|
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
|
||||||
<Show
|
<Show
|
||||||
when={props.session}
|
when={props.session()}
|
||||||
keyed
|
keyed
|
||||||
fallback={
|
fallback={
|
||||||
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
||||||
@@ -267,7 +267,7 @@ export function TabNavItem(props: {
|
|||||||
}
|
}
|
||||||
if (event.key !== "Escape") return
|
if (event.key !== "Escape") return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
titleEl.textContent = props.session?.title ?? ""
|
titleEl.textContent = props.session()?.title ?? ""
|
||||||
void closeRename(false)
|
void closeRename(false)
|
||||||
}}
|
}}
|
||||||
onBlur={() => void closeRename(true)}
|
onBlur={() => void closeRename(true)}
|
||||||
@@ -308,7 +308,7 @@ export function TabNavItem(props: {
|
|||||||
}}
|
}}
|
||||||
data={{
|
data={{
|
||||||
projectName: projectName(),
|
projectName: projectName(),
|
||||||
title: props.session?.title,
|
title: props.session()?.title,
|
||||||
path: previewPath(),
|
path: previewPath(),
|
||||||
serverName: serverLabel(),
|
serverName: serverLabel(),
|
||||||
}}
|
}}
|
||||||
@@ -323,7 +323,7 @@ export function DraftTabItem(props: {
|
|||||||
active?: boolean
|
active?: boolean
|
||||||
onNavigate: () => void
|
onNavigate: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
suppressNavigation?: boolean
|
suppressNavigation?: () => boolean
|
||||||
dragging?: boolean
|
dragging?: boolean
|
||||||
pressed?: boolean
|
pressed?: boolean
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
@@ -366,14 +366,14 @@ export function DraftTabItem(props: {
|
|||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||||
if (event.button !== 0) return
|
if (event.button !== 0) return
|
||||||
if (props.suppressNavigation) return
|
if (props.suppressNavigation?.()) return
|
||||||
props.onNavigate()
|
props.onNavigate()
|
||||||
}}
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||||
if (event.detail > 0) return
|
if (event.detail > 0) return
|
||||||
if (props.suppressNavigation) return
|
if (props.suppressNavigation?.()) return
|
||||||
props.onNavigate()
|
props.onNavigate()
|
||||||
}}
|
}}
|
||||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ import type { SessionInfo } from "@opencode-ai/client/promise"
|
|||||||
function SessionTabSlot(props: {
|
function SessionTabSlot(props: {
|
||||||
tab: SessionTab
|
tab: SessionTab
|
||||||
id: string
|
id: string
|
||||||
index: number
|
index: () => number
|
||||||
active: boolean
|
active: () => boolean
|
||||||
forceTruncate: boolean
|
forceTruncate: boolean
|
||||||
session: SessionInfo | undefined
|
session: () => SessionInfo | undefined
|
||||||
fallbackTitle?: string
|
fallbackTitle?: string
|
||||||
onRename: (title: string) => Promise<void>
|
onRename: (title: string) => Promise<void>
|
||||||
onNavigate: (element: HTMLDivElement) => void
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
@@ -38,7 +38,7 @@ function SessionTabSlot(props: {
|
|||||||
return props.id
|
return props.id
|
||||||
},
|
},
|
||||||
get index() {
|
get index() {
|
||||||
return props.index
|
return props.index()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
@@ -48,7 +48,7 @@ function SessionTabSlot(props: {
|
|||||||
ref={sortable.ref}
|
ref={sortable.ref}
|
||||||
data-titlebar-tab-slot
|
data-titlebar-tab-slot
|
||||||
data-tab-key={props.id}
|
data-tab-key={props.id}
|
||||||
data-active={props.active}
|
data-active={props.active()}
|
||||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||||
>
|
>
|
||||||
<TabNavItem
|
<TabNavItem
|
||||||
@@ -62,7 +62,7 @@ function SessionTabSlot(props: {
|
|||||||
onRename={props.onRename}
|
onRename={props.onRename}
|
||||||
onNavigate={() => props.onNavigate(ref)}
|
onNavigate={() => props.onNavigate(ref)}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
active={props.active}
|
active={props.active()}
|
||||||
forceTruncate={props.forceTruncate}
|
forceTruncate={props.forceTruncate}
|
||||||
dragging={sortable.isDragSource()}
|
dragging={sortable.isDragSource()}
|
||||||
/>
|
/>
|
||||||
@@ -73,34 +73,34 @@ function SessionTabSlot(props: {
|
|||||||
function SessionTabEntry(props: {
|
function SessionTabEntry(props: {
|
||||||
tab: SessionTab
|
tab: SessionTab
|
||||||
id: string
|
id: string
|
||||||
index: number
|
index: () => number
|
||||||
active: boolean
|
active: () => boolean
|
||||||
forceTruncate: boolean
|
forceTruncate: boolean
|
||||||
serverCtx: ServerCtx | undefined
|
serverCtx: () => ServerCtx | undefined
|
||||||
onVisibleChange: (visible: boolean) => void
|
onVisibleChange: (visible: boolean) => void
|
||||||
onNavigate: (element: HTMLDivElement) => void
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
|
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
||||||
const cachedSession = createMemo(() => props.serverCtx?.sync.session.peek(props.tab.sessionId))
|
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
||||||
const persisted = createMemo(() => tabs.info[props.id])
|
const persisted = createMemo(() => tabs.info[props.id])
|
||||||
const [loadedSession] = createResource(
|
const [loadedSession] = createResource(
|
||||||
() => {
|
() => {
|
||||||
const ctx = props.serverCtx
|
const ctx = props.serverCtx()
|
||||||
return ctx ? { id: props.tab.sessionId, ctx } : null
|
return ctx ? { id: props.tab.sessionId, ctx } : null
|
||||||
},
|
},
|
||||||
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
||||||
)
|
)
|
||||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||||
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session())
|
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
|
||||||
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
|
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
|
||||||
let prefetched = false
|
let prefetched = false
|
||||||
|
|
||||||
const rename = async (title: string) => {
|
const rename = async (title: string) => {
|
||||||
const value = session()
|
const value = session()
|
||||||
const ctx = props.serverCtx
|
const ctx = props.serverCtx()
|
||||||
if (!value || !ctx) return
|
if (!value || !ctx) return
|
||||||
|
|
||||||
ctx.sync.session.remember({ ...value, title })
|
ctx.sync.session.remember({ ...value, title })
|
||||||
@@ -108,7 +108,7 @@ function SessionTabEntry(props: {
|
|||||||
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const current = session()
|
const current = session()
|
||||||
const currentCtx = props.serverCtx
|
const currentCtx = props.serverCtx()
|
||||||
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
|
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("common.requestFailed"),
|
title: language.t("common.requestFailed"),
|
||||||
@@ -120,7 +120,7 @@ function SessionTabEntry(props: {
|
|||||||
createEffect(() => props.onVisibleChange(visible()))
|
createEffect(() => props.onVisibleChange(visible()))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const ctx = props.serverCtx
|
const ctx = props.serverCtx()
|
||||||
const value = session()
|
const value = session()
|
||||||
if (!ctx || !value || prefetched) return
|
if (!ctx || !value || prefetched) return
|
||||||
prefetched = true
|
prefetched = true
|
||||||
@@ -157,7 +157,7 @@ function SessionTabEntry(props: {
|
|||||||
index={props.index}
|
index={props.index}
|
||||||
active={props.active}
|
active={props.active}
|
||||||
forceTruncate={props.forceTruncate}
|
forceTruncate={props.forceTruncate}
|
||||||
session={session()}
|
session={session}
|
||||||
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
|
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
|
||||||
onRename={rename}
|
onRename={rename}
|
||||||
onNavigate={props.onNavigate}
|
onNavigate={props.onNavigate}
|
||||||
@@ -170,8 +170,8 @@ function SessionTabEntry(props: {
|
|||||||
function DraftTabSlot(props: {
|
function DraftTabSlot(props: {
|
||||||
tab: Extract<Tab, { type: "draft" }>
|
tab: Extract<Tab, { type: "draft" }>
|
||||||
id: string
|
id: string
|
||||||
index: number
|
index: () => number
|
||||||
active: boolean
|
active: () => boolean
|
||||||
title: string
|
title: string
|
||||||
onNavigate: (element: HTMLDivElement) => void
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -181,7 +181,7 @@ function DraftTabSlot(props: {
|
|||||||
return props.id
|
return props.id
|
||||||
},
|
},
|
||||||
get index() {
|
get index() {
|
||||||
return props.index
|
return props.index()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
@@ -191,7 +191,7 @@ function DraftTabSlot(props: {
|
|||||||
ref={sortable.ref}
|
ref={sortable.ref}
|
||||||
data-titlebar-tab-slot
|
data-titlebar-tab-slot
|
||||||
data-tab-key={props.id}
|
data-tab-key={props.id}
|
||||||
data-active={props.active}
|
data-active={props.active()}
|
||||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||||
>
|
>
|
||||||
<DraftTabItem
|
<DraftTabItem
|
||||||
@@ -202,7 +202,7 @@ function DraftTabSlot(props: {
|
|||||||
title={props.title}
|
title={props.title}
|
||||||
onNavigate={() => props.onNavigate(ref)}
|
onNavigate={() => props.onNavigate(ref)}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
active={props.active}
|
active={props.active()}
|
||||||
dragging={sortable.isDragSource()}
|
dragging={sortable.isDragSource()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +211,7 @@ function DraftTabSlot(props: {
|
|||||||
|
|
||||||
export function TitlebarTabStrip(props: {
|
export function TitlebarTabStrip(props: {
|
||||||
tabs: Tab[]
|
tabs: Tab[]
|
||||||
currentTab: Tab | undefined
|
currentTab: () => Tab | undefined
|
||||||
forceTruncate: boolean
|
forceTruncate: boolean
|
||||||
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
||||||
onClose: (tab: Tab) => void
|
onClose: (tab: Tab) => void
|
||||||
@@ -248,7 +248,7 @@ export function TitlebarTabStrip(props: {
|
|||||||
])
|
])
|
||||||
|
|
||||||
function selectAdjacentTab(offset: -1 | 1) {
|
function selectAdjacentTab(offset: -1 | 1) {
|
||||||
const current = props.currentTab
|
const current = props.currentTab()
|
||||||
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
|
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
|
||||||
const next = props.tabs.find((tab) => tabKey(tab) === key)
|
const next = props.tabs.find((tab) => tabKey(tab) === key)
|
||||||
if (next) props.onNavigate(next)
|
if (next) props.onNavigate(next)
|
||||||
@@ -350,10 +350,10 @@ export function TitlebarTabStrip(props: {
|
|||||||
<SessionTabEntry
|
<SessionTabEntry
|
||||||
tab={tab}
|
tab={tab}
|
||||||
id={id}
|
id={id}
|
||||||
index={visibleIndex()}
|
index={visibleIndex}
|
||||||
active={props.currentTab === tab}
|
active={() => props.currentTab() === tab}
|
||||||
forceTruncate={props.forceTruncate}
|
forceTruncate={props.forceTruncate}
|
||||||
serverCtx={serverCtx()}
|
serverCtx={serverCtx}
|
||||||
onVisibleChange={(visible) => setVisibility(id, visible)}
|
onVisibleChange={(visible) => setVisibility(id, visible)}
|
||||||
onNavigate={(element) => {
|
onNavigate={(element) => {
|
||||||
ref = element
|
ref = element
|
||||||
@@ -368,8 +368,8 @@ export function TitlebarTabStrip(props: {
|
|||||||
<DraftTabSlot
|
<DraftTabSlot
|
||||||
tab={tab}
|
tab={tab}
|
||||||
id={id}
|
id={id}
|
||||||
index={visibleIndex()}
|
index={visibleIndex}
|
||||||
active={props.currentTab === tab}
|
active={() => props.currentTab() === tab}
|
||||||
title={language.t("command.session.new")}
|
title={language.t("command.session.new")}
|
||||||
onNavigate={(element) => {
|
onNavigate={(element) => {
|
||||||
ref = element
|
ref = element
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px
|
|||||||
const macTrafficLightsBaseWidth = 84
|
const macTrafficLightsBaseWidth = 84
|
||||||
|
|
||||||
export type TitlebarUpdate = {
|
export type TitlebarUpdate = {
|
||||||
version: string | undefined
|
version: () => string | undefined
|
||||||
installing: boolean
|
installing: () => boolean
|
||||||
install: () => void
|
install: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,8 +121,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||||
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
|
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
|
||||||
const updateState = createMemo<TitlebarUpdatePillState>(() => {
|
const updateState = createMemo<TitlebarUpdatePillState>(() => {
|
||||||
const installing = props.update?.installing ?? false
|
const installing = props.update?.installing() ?? false
|
||||||
const version = props.update?.version
|
const version = props.update?.version()
|
||||||
return {
|
return {
|
||||||
visible: version !== undefined || installing,
|
visible: version !== undefined || installing,
|
||||||
installing,
|
installing,
|
||||||
@@ -392,7 +392,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
|
|
||||||
<TitlebarTabStrip
|
<TitlebarTabStrip
|
||||||
tabs={tabsStore}
|
tabs={tabsStore}
|
||||||
currentTab={currentTab()}
|
currentTab={currentTab}
|
||||||
forceTruncate={tabsAreOverflowing()}
|
forceTruncate={tabsAreOverflowing()}
|
||||||
onOverflowChange={setTabsAreOverflowing}
|
onOverflowChange={setTabsAreOverflowing}
|
||||||
onNavigate={(tab, el) => {
|
onNavigate={(tab, el) => {
|
||||||
@@ -657,10 +657,12 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={["local", "beta", "dev"].includes(channel)}>
|
<>
|
||||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
{["local", "beta", "dev"].includes(channel) && (
|
||||||
{channel.toUpperCase()}
|
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||||
</div>
|
{channel.toUpperCase()}
|
||||||
</Show>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, Show, type JSX } from "solid-js"
|
import { Show, type JSX } from "solid-js"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
@@ -50,20 +50,7 @@ export function WindowsAppMenu(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
|
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
|
||||||
<Show
|
{props.variant === "v2" ? (
|
||||||
when={props.variant === "v2"}
|
|
||||||
fallback={
|
|
||||||
<DropdownMenu.Trigger
|
|
||||||
as={IconButton}
|
|
||||||
icon="menu"
|
|
||||||
variant="ghost"
|
|
||||||
class="titlebar-icon rounded-md shrink-0"
|
|
||||||
aria-label={language.t("desktop.menu.ariaLabel")}
|
|
||||||
onPointerDown={rememberFocus}
|
|
||||||
onKeyDown={rememberFocus}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
data-component="desktop-icon-button"
|
data-component="desktop-icon-button"
|
||||||
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
|
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
|
||||||
@@ -78,31 +65,39 @@ export function WindowsAppMenu(props: {
|
|||||||
onKeyDown={rememberFocus}
|
onKeyDown={rememberFocus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
) : (
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
as={IconButton}
|
||||||
|
icon="menu"
|
||||||
|
variant="ghost"
|
||||||
|
class="titlebar-icon rounded-md shrink-0"
|
||||||
|
aria-label={language.t("desktop.menu.ariaLabel")}
|
||||||
|
onPointerDown={rememberFocus}
|
||||||
|
onKeyDown={rememberFocus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content class="desktop-app-menu">
|
<DropdownMenu.Content class="desktop-app-menu">
|
||||||
<DropdownMenu.Group>
|
<DropdownMenu.Group>
|
||||||
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
|
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
|
||||||
<For each={DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows"))}>
|
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
|
||||||
{(menu) => (
|
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
|
||||||
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
|
{menu.items
|
||||||
<For each={menu.items?.filter((entry) => desktopMenuVisible(entry, "windows"))}>
|
?.filter((entry) => desktopMenuVisible(entry, "windows"))
|
||||||
{(entry) => {
|
.map((entry) =>
|
||||||
// Static menu data: an early return keeps the union narrowing a Show fallback would lose.
|
entry.type === "separator" ? (
|
||||||
if (entry.type === "separator") return <DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
return (
|
) : (
|
||||||
<DesktopMenuItem
|
<DesktopMenuItem
|
||||||
label={entry.labelKey ? language.t(entry.labelKey) : ""}
|
label={entry.labelKey ? language.t(entry.labelKey) : ""}
|
||||||
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
|
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
|
||||||
disabled={entry.command ? commandDisabled(entry.command) : false}
|
disabled={entry.command ? commandDisabled(entry.command) : false}
|
||||||
onSelect={() => runEntry(entry)}
|
onSelect={() => runEntry(entry)}
|
||||||
/>
|
/>
|
||||||
)
|
),
|
||||||
}}
|
)}
|
||||||
</For>
|
</DesktopMenuSubmenu>
|
||||||
</DesktopMenuSubmenu>
|
))}
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</DropdownMenu.Group>
|
</DropdownMenu.Group>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
|
|||||||
const refresh: string[] = []
|
const refresh: string[] = []
|
||||||
invalidateFromWatcher(
|
invalidateFromWatcher(
|
||||||
{
|
{
|
||||||
type: "filesystem.changed",
|
type: "file.watcher.updated",
|
||||||
properties: {
|
properties: {
|
||||||
file: "src/new.ts",
|
file: "src/new.ts",
|
||||||
event: "add",
|
event: "add",
|
||||||
@@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
|
|||||||
|
|
||||||
invalidateFromWatcher(
|
invalidateFromWatcher(
|
||||||
{
|
{
|
||||||
type: "filesystem.changed",
|
type: "file.watcher.updated",
|
||||||
properties: {
|
properties: {
|
||||||
file: "src/open.ts",
|
file: "src/open.ts",
|
||||||
event: "change",
|
event: "change",
|
||||||
@@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
|
|||||||
|
|
||||||
invalidateFromWatcher(
|
invalidateFromWatcher(
|
||||||
{
|
{
|
||||||
type: "filesystem.changed",
|
type: "file.watcher.updated",
|
||||||
properties: {
|
properties: {
|
||||||
file: "src",
|
file: "src",
|
||||||
event: "change",
|
event: "change",
|
||||||
@@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
|
|||||||
|
|
||||||
invalidateFromWatcher(
|
invalidateFromWatcher(
|
||||||
{
|
{
|
||||||
type: "filesystem.changed",
|
type: "file.watcher.updated",
|
||||||
properties: {
|
properties: {
|
||||||
file: "src/file.ts",
|
file: "src/file.ts",
|
||||||
event: "change",
|
event: "change",
|
||||||
@@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
|
|||||||
|
|
||||||
invalidateFromWatcher(
|
invalidateFromWatcher(
|
||||||
{
|
{
|
||||||
type: "filesystem.changed",
|
type: "file.watcher.updated",
|
||||||
properties: {
|
properties: {
|
||||||
file: ".git/index.lock",
|
file: ".git/index.lock",
|
||||||
event: "change",
|
event: "change",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type WatcherOps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||||
if (event.type !== "filesystem.changed") return
|
if (event.type !== "file.watcher.updated") return
|
||||||
const props =
|
const props =
|
||||||
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
||||||
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import type {
|
|||||||
CommandInfo,
|
CommandInfo,
|
||||||
CommandListInput,
|
CommandListInput,
|
||||||
CommandListOutput,
|
CommandListOutput,
|
||||||
IntegrationListInput,
|
|
||||||
IntegrationListOutput,
|
|
||||||
LocationGetInput,
|
LocationGetInput,
|
||||||
LocationGetOutput,
|
LocationGetOutput,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
@@ -26,9 +24,9 @@ import { getFilename } from "@opencode-ai/core/util/path"
|
|||||||
import { retry } from "@opencode-ai/core/util/retry"
|
import { retry } from "@opencode-ai/core/util/retry"
|
||||||
import { batch } from "solid-js"
|
import { batch } from "solid-js"
|
||||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type { State } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import type { ServerSession } from "../server-session"
|
import type { ServerSession } from "../server-session"
|
||||||
import { cmp, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
|
import { cmp, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||||
@@ -223,10 +221,6 @@ type CommandListApi = {
|
|||||||
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
|
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
|
||||||
}
|
}
|
||||||
|
|
||||||
type IntegrationListApi = {
|
|
||||||
readonly list: (input?: IntegrationListInput) => Promise<IntegrationListOutput>
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReferenceListApi = {
|
type ReferenceListApi = {
|
||||||
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
||||||
}
|
}
|
||||||
@@ -237,13 +231,6 @@ export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: Agen
|
|||||||
queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
|
queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadIntegrationsQuery = (scope: ServerScope, directory: string | null, sdk: IntegrationListApi) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: [scope, directory, "integrations"] as const,
|
|
||||||
queryFn: () =>
|
|
||||||
retry(() => sdk.list(directory ? { location: { directory } } : undefined).then((result) => result.data)),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> =>
|
export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> =>
|
||||||
retry(() => api.list({ location: { directory } }).then((result) => result.data))
|
retry(() => api.list({ location: { directory } }).then((result) => result.data))
|
||||||
|
|
||||||
@@ -285,6 +272,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
}
|
}
|
||||||
store: Store<State>
|
store: Store<State>
|
||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
|
vcsCache: VcsCache
|
||||||
loadSessions: (directory: string) => Promise<void> | void
|
loadSessions: (directory: string) => Promise<void> | void
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
global: {
|
global: {
|
||||||
@@ -309,109 +297,104 @@ export async function bootstrapDirectory(input: {
|
|||||||
const revKey = ScopedKey.from(input.scope, input.directory)
|
const revKey = ScopedKey.from(input.scope, input.directory)
|
||||||
const rev = (providerRev.get(revKey) ?? 0) + 1
|
const rev = (providerRev.get(revKey) ?? 0) + 1
|
||||||
providerRev.set(revKey, rev)
|
providerRev.set(revKey, rev)
|
||||||
const slow = [
|
;(async () => {
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
const slow = [
|
||||||
() =>
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
input.queryClient
|
() =>
|
||||||
.ensureQueryData(loadAgentsQuery(input.scope, directoryKey(input.directory), input.api.agent))
|
|
||||||
.then((data) => input.setStore("agent", data)),
|
|
||||||
!seededProject &&
|
|
||||||
(() =>
|
|
||||||
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
|
||||||
input.setStore("project", project.id),
|
|
||||||
)),
|
|
||||||
!seededPath &&
|
|
||||||
(() =>
|
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.ensureQueryData(loadPathQuery(input.scope, directoryKey(input.directory), input.api.location))
|
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
|
||||||
.then((data) => {
|
.then((data) => input.setStore("agent", data)),
|
||||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
!seededProject &&
|
||||||
if (next) input.setStore("project", next)
|
(() =>
|
||||||
})),
|
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||||
input.mcp &&
|
input.setStore("project", project.id),
|
||||||
(() => loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
|
)),
|
||||||
() =>
|
!seededPath &&
|
||||||
input.queryClient.fetchQuery(
|
(() =>
|
||||||
loadReferencesQuery(input.scope, directoryKey(input.directory), input.api.reference),
|
input.queryClient
|
||||||
),
|
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.location))
|
||||||
() =>
|
.then((data) => {
|
||||||
retry(() =>
|
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||||
input.api.permission.request
|
if (next) input.setStore("project", next)
|
||||||
.list({ location: { directory: input.directory } })
|
})),
|
||||||
.then((result) => result.data)
|
input.mcp &&
|
||||||
.then((permissions) => {
|
(() =>
|
||||||
const ids = permissions.map((permission) => permission.sessionID)
|
loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
|
||||||
const grouped = groupBySession(
|
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.api.reference)),
|
||||||
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
() =>
|
||||||
)
|
retry(() =>
|
||||||
const warm = input.session
|
input.api.permission.request
|
||||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
.list({ location: { directory: input.directory } })
|
||||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
.then((result) => result.data)
|
||||||
return warm.then(() =>
|
.then((permissions) => {
|
||||||
batch(() => {
|
const ids = permissions.map((permission) => permission.sessionID)
|
||||||
const current = input.session?.data.permission ?? input.store.permission
|
const grouped = groupBySession(
|
||||||
for (const sessionID of Object.keys(current)) {
|
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
||||||
if (grouped[sessionID]) continue
|
)
|
||||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
const warm = input.session
|
||||||
if (input.session) input.session.set("permission", sessionID, [])
|
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||||
if (!input.session) input.setStore("permission", sessionID, [])
|
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||||
}
|
return warm.then(() =>
|
||||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
batch(() => {
|
||||||
const value = reconcile(
|
const current = input.session?.data.permission ?? input.store.permission
|
||||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
for (const sessionID of Object.keys(current)) {
|
||||||
{ key: "id" },
|
if (grouped[sessionID]) continue
|
||||||
)
|
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||||
if (input.session) input.session.set("permission", sessionID, value)
|
if (input.session) input.session.set("permission", sessionID, [])
|
||||||
if (!input.session) input.setStore("permission", sessionID, value)
|
if (!input.session) input.setStore("permission", sessionID, [])
|
||||||
}
|
}
|
||||||
}),
|
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||||
)
|
const value = reconcile(
|
||||||
}),
|
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
),
|
{ key: "id" },
|
||||||
() =>
|
)
|
||||||
retry(() =>
|
if (input.session) input.session.set("permission", sessionID, value)
|
||||||
input.api.question.request
|
if (!input.session) input.setStore("permission", sessionID, value)
|
||||||
.list({ location: { directory: input.directory } })
|
}
|
||||||
.then((result) => result.data)
|
}),
|
||||||
.then((questions) => {
|
)
|
||||||
const ids = questions.map((question) => question.sessionID)
|
}),
|
||||||
const grouped = groupBySession(
|
),
|
||||||
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
() =>
|
||||||
)
|
retry(() =>
|
||||||
const warm = input.session
|
input.api.question.request
|
||||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
.list({ location: { directory: input.directory } })
|
||||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
.then((result) => result.data)
|
||||||
return warm.then(() =>
|
.then((questions) => {
|
||||||
batch(() => {
|
const ids = questions.map((question) => question.sessionID)
|
||||||
const current = input.session?.data.question ?? input.store.question
|
const grouped = groupBySession(
|
||||||
for (const sessionID of Object.keys(current)) {
|
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
||||||
if (grouped[sessionID]) continue
|
)
|
||||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
const warm = input.session
|
||||||
if (input.session) input.session.set("question", sessionID, [])
|
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||||
if (!input.session) input.setStore("question", sessionID, [])
|
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||||
}
|
return warm.then(() =>
|
||||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
batch(() => {
|
||||||
const value = reconcile(
|
const current = input.session?.data.question ?? input.store.question
|
||||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
for (const sessionID of Object.keys(current)) {
|
||||||
{ key: "id" },
|
if (grouped[sessionID]) continue
|
||||||
)
|
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||||
if (input.session) input.session.set("question", sessionID, value)
|
if (input.session) input.session.set("question", sessionID, [])
|
||||||
if (!input.session) input.setStore("question", sessionID, value)
|
if (!input.session) input.setStore("question", sessionID, [])
|
||||||
}
|
}
|
||||||
}),
|
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||||
)
|
const value = reconcile(
|
||||||
}),
|
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
),
|
{ key: "id" },
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
)
|
||||||
input.mcp &&
|
if (input.session) input.session.set("question", sessionID, value)
|
||||||
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
if (!input.session) input.setStore("question", sessionID, value)
|
||||||
input.mcp &&
|
}
|
||||||
(() =>
|
}),
|
||||||
input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
)
|
||||||
() =>
|
}),
|
||||||
input.queryClient
|
),
|
||||||
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api))
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
.catch((err) => {
|
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))),
|
||||||
|
input.mcp &&
|
||||||
|
(() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))),
|
||||||
|
() =>
|
||||||
|
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api)).catch((err) => {
|
||||||
const project = getFilename(input.directory)
|
const project = getFilename(input.directory)
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
@@ -419,19 +402,20 @@ export async function bootstrapDirectory(input: {
|
|||||||
description: formatServerError(err, input.translate),
|
description: formatServerError(err, input.translate),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
].filter(Boolean) as (() => Promise<any>)[]
|
].filter(Boolean) as (() => Promise<any>)[]
|
||||||
|
|
||||||
await waitForPaint()
|
await waitForPaint()
|
||||||
const slowErrs = errors(await runAll(slow))
|
const slowErrs = errors(await runAll(slow))
|
||||||
if (slowErrs.length > 0) {
|
if (slowErrs.length > 0) {
|
||||||
console.error("Failed to finish bootstrap instance", slowErrs[0])
|
console.error("Failed to finish bootstrap instance", slowErrs[0])
|
||||||
const project = getFilename(input.directory)
|
const project = getFilename(input.directory)
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||||
description: formatServerError(slowErrs[0], input.translate),
|
description: formatServerError(slowErrs[0], input.translate),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading && slowErrs.length === 0) input.setStore("status", "complete")
|
if (loading && slowErrs.length === 0) input.setStore("status", "complete")
|
||||||
|
})()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { ServerScope } from "@/utils/server-scope"
|
|||||||
|
|
||||||
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
||||||
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
|
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
|
||||||
let providerQuerySuccess = true
|
|
||||||
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
|
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
|
||||||
store[0],
|
store[0],
|
||||||
store[1],
|
store[1],
|
||||||
@@ -59,17 +58,11 @@ beforeAll(async () => {
|
|||||||
get isLoading() {
|
get isLoading() {
|
||||||
return options().queryKey?.[1] === "path"
|
return options().queryKey?.[1] === "path"
|
||||||
},
|
},
|
||||||
get isSuccess() {
|
|
||||||
return options().queryKey?.[1] === "providers" && options().enabled === true && providerQuerySuccess
|
|
||||||
},
|
|
||||||
get isRefetchError() {
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
get data() {
|
get data() {
|
||||||
if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
|
if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
|
||||||
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
|
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
|
||||||
if (options().queryKey?.[1] === "lsp") return []
|
if (options().queryKey?.[1] === "lsp") return []
|
||||||
if (options().queryKey?.[1] === "providers") return providerQuerySuccess ? provider : undefined
|
if (options().queryKey?.[1] === "providers") return provider
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -90,7 +83,6 @@ describe("createChildStoreManager", () => {
|
|||||||
|
|
||||||
const manager = createChildStoreManager({
|
const manager = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
connected: () => true,
|
|
||||||
scope: ServerScope.local,
|
scope: ServerScope.local,
|
||||||
persist,
|
persist,
|
||||||
isBooting: () => false,
|
isBooting: () => false,
|
||||||
@@ -122,7 +114,6 @@ describe("createChildStoreManager", () => {
|
|||||||
const dispose = createOwner((owner) => {
|
const dispose = createOwner((owner) => {
|
||||||
manager = createChildStoreManager({
|
manager = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
connected: () => true,
|
|
||||||
scope: ServerScope.local,
|
scope: ServerScope.local,
|
||||||
persist,
|
persist,
|
||||||
isBooting: () => false,
|
isBooting: () => false,
|
||||||
@@ -157,7 +148,6 @@ describe("createChildStoreManager", () => {
|
|||||||
const dispose = createOwner((owner) => {
|
const dispose = createOwner((owner) => {
|
||||||
manager = createChildStoreManager({
|
manager = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
connected: () => true,
|
|
||||||
scope: ServerScope.local,
|
scope: ServerScope.local,
|
||||||
persist,
|
persist,
|
||||||
isBooting: () => false,
|
isBooting: () => false,
|
||||||
@@ -183,37 +173,6 @@ describe("createChildStoreManager", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("writes refreshed VCS data to the child store", () => {
|
|
||||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
|
||||||
const dispose = createOwner((owner) => {
|
|
||||||
manager = createChildStoreManager({
|
|
||||||
owner,
|
|
||||||
connected: () => true,
|
|
||||||
scope: ServerScope.local,
|
|
||||||
persist,
|
|
||||||
isBooting: () => false,
|
|
||||||
isLoadingSessions: () => false,
|
|
||||||
onBootstrap() {},
|
|
||||||
onMcp() {},
|
|
||||||
onDispose() {},
|
|
||||||
translate: (key) => key,
|
|
||||||
queryOptions: queryOptionsApi,
|
|
||||||
global: { provider },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!manager) throw new Error("manager required")
|
|
||||||
const [store] = manager.child("/project", { bootstrap: false })
|
|
||||||
|
|
||||||
manager.vcs("/project", { branch: "feature", default_branch: "main" })
|
|
||||||
|
|
||||||
expect(store.vcs).toEqual({ branch: "feature", default_branch: "main" })
|
|
||||||
} finally {
|
|
||||||
dispose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("enables MCP only when requested for the directory", () => {
|
test("enables MCP only when requested for the directory", () => {
|
||||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||||
const offset = querySingles.length
|
const offset = querySingles.length
|
||||||
@@ -222,7 +181,6 @@ describe("createChildStoreManager", () => {
|
|||||||
const dispose = createOwner((owner) => {
|
const dispose = createOwner((owner) => {
|
||||||
manager = createChildStoreManager({
|
manager = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
connected: () => true,
|
|
||||||
scope: ServerScope.local,
|
scope: ServerScope.local,
|
||||||
persist,
|
persist,
|
||||||
isBooting: () => false,
|
isBooting: () => false,
|
||||||
@@ -272,7 +230,6 @@ describe("createChildStoreManager", () => {
|
|||||||
const dispose = createOwner((owner) => {
|
const dispose = createOwner((owner) => {
|
||||||
manager = createChildStoreManager({
|
manager = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
connected: () => true,
|
|
||||||
scope: ServerScope.local,
|
scope: ServerScope.local,
|
||||||
persist,
|
persist,
|
||||||
isBooting: () => false,
|
isBooting: () => false,
|
||||||
@@ -316,71 +273,4 @@ describe("createChildStoreManager", () => {
|
|||||||
dispose()
|
dispose()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not mark a cancelled provider query as ready", () => {
|
|
||||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
|
||||||
providerQuerySuccess = false
|
|
||||||
|
|
||||||
const dispose = createOwner((owner) => {
|
|
||||||
manager = createChildStoreManager({
|
|
||||||
owner,
|
|
||||||
connected: () => true,
|
|
||||||
scope: ServerScope.local,
|
|
||||||
persist,
|
|
||||||
isBooting: () => false,
|
|
||||||
isLoadingSessions: () => false,
|
|
||||||
onBootstrap() {},
|
|
||||||
onMcp() {},
|
|
||||||
onDispose() {},
|
|
||||||
translate: (key) => key,
|
|
||||||
queryOptions: queryOptionsApi,
|
|
||||||
global: { provider },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!manager) throw new Error("manager required")
|
|
||||||
const [store] = manager.child("/cancelled")
|
|
||||||
expect(store.provider_ready).toBe(false)
|
|
||||||
} finally {
|
|
||||||
providerQuerySuccess = true
|
|
||||||
dispose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not enable location queries before the event handshake", () => {
|
|
||||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
|
||||||
let connected = false
|
|
||||||
const offset = querySingles.length
|
|
||||||
const dispose = createOwner((owner) => {
|
|
||||||
manager = createChildStoreManager({
|
|
||||||
owner,
|
|
||||||
connected: () => connected,
|
|
||||||
scope: ServerScope.local,
|
|
||||||
persist,
|
|
||||||
isBooting: () => false,
|
|
||||||
isLoadingSessions: () => false,
|
|
||||||
onBootstrap() {},
|
|
||||||
onMcp() {},
|
|
||||||
onDispose() {},
|
|
||||||
translate: (key) => key,
|
|
||||||
queryOptions: queryOptionsApi,
|
|
||||||
global: { provider },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!manager) throw new Error("manager required")
|
|
||||||
manager.child("/handshake")
|
|
||||||
const queries = querySingles.slice(offset)
|
|
||||||
expect(queries[0]?.().enabled).toBe(false)
|
|
||||||
expect(queries[4]?.().enabled).toBe(false)
|
|
||||||
|
|
||||||
connected = true
|
|
||||||
expect(queries[0]?.().enabled).toBe(true)
|
|
||||||
expect(queries[4]?.().enabled).toBe(true)
|
|
||||||
} finally {
|
|
||||||
dispose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { VcsInfo } from "@/types"
|
import type { VcsInfo } from "@/types"
|
||||||
@@ -22,7 +22,6 @@ import type { ServerScope } from "@/utils/server-scope"
|
|||||||
|
|
||||||
export function createChildStoreManager(input: {
|
export function createChildStoreManager(input: {
|
||||||
owner: Owner
|
owner: Owner
|
||||||
connected: Accessor<boolean>
|
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
persist: typeof persisted
|
persist: typeof persisted
|
||||||
isBooting: (directory: string) => boolean
|
isBooting: (directory: string) => boolean
|
||||||
@@ -189,29 +188,17 @@ export function createChildStoreManager(input: {
|
|||||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||||
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
||||||
|
|
||||||
const pathQuery = useQuery(() => ({
|
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
||||||
...input.queryOptions.path(key),
|
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
||||||
enabled: input.connected() && instanceQueriesEnabled(),
|
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
|
||||||
}))
|
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
|
||||||
const mcpQuery = useQuery(() => ({
|
|
||||||
...input.queryOptions.mcp(key),
|
|
||||||
enabled: input.connected() && mcpEnabled(),
|
|
||||||
}))
|
|
||||||
const mcpResourceQuery = useQuery(() => ({
|
|
||||||
...input.queryOptions.mcpResources(key),
|
|
||||||
enabled: input.connected() && mcpEnabled(),
|
|
||||||
}))
|
|
||||||
const lspQuery = useQuery(() => ({
|
|
||||||
...input.queryOptions.lsp(key),
|
|
||||||
enabled: input.connected() && instanceQueriesEnabled(),
|
|
||||||
}))
|
|
||||||
const providerQuery = useQuery(() => ({
|
const providerQuery = useQuery(() => ({
|
||||||
...input.queryOptions.providers(key),
|
...input.queryOptions.providers(key),
|
||||||
enabled: input.connected() && instanceQueriesEnabled(),
|
enabled: instanceQueriesEnabled(),
|
||||||
}))
|
}))
|
||||||
const referenceQuery = useQuery(() => ({
|
const referenceQuery = useQuery(() => ({
|
||||||
...input.queryOptions.references(key),
|
...input.queryOptions.references(key),
|
||||||
enabled: input.connected() && instanceQueriesEnabled(),
|
enabled: instanceQueriesEnabled(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const child = createStore<State>({
|
const child = createStore<State>({
|
||||||
@@ -219,14 +206,13 @@ export function createChildStoreManager(input: {
|
|||||||
projectMeta: initialMeta,
|
projectMeta: initialMeta,
|
||||||
icon: initialIcon,
|
icon: initialIcon,
|
||||||
get provider_ready() {
|
get provider_ready() {
|
||||||
return instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError)
|
return instanceQueriesEnabled() && !providerQuery.isLoading
|
||||||
},
|
},
|
||||||
get provider() {
|
get provider() {
|
||||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||||
if (!providerQuery.isSuccess && !providerQuery.isRefetchError) return EMPTY
|
if (providerQuery.isLoading) return EMPTY
|
||||||
const provider = providerQuery.data
|
if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
|
||||||
if (provider.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
|
return providerQuery.data ?? EMPTY
|
||||||
return provider
|
|
||||||
},
|
},
|
||||||
config: {},
|
config: {},
|
||||||
get path() {
|
get path() {
|
||||||
@@ -387,15 +373,6 @@ export function createChildStoreManager(input: {
|
|||||||
setStore("icon", value)
|
setStore("icon", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function vcs(directory: string, value: VcsInfo) {
|
|
||||||
const key = directoryKey(directory)
|
|
||||||
const child = ensureChild(directory)
|
|
||||||
const cached = vcsCache.get(key)
|
|
||||||
if (!cached) return
|
|
||||||
cached.setStore("value", value)
|
|
||||||
child[1]("vcs", value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
children,
|
children,
|
||||||
ensureChild,
|
ensureChild,
|
||||||
@@ -403,7 +380,6 @@ export function createChildStoreManager(input: {
|
|||||||
peek,
|
peek,
|
||||||
projectMeta,
|
projectMeta,
|
||||||
projectIcon,
|
projectIcon,
|
||||||
vcs,
|
|
||||||
mark,
|
mark,
|
||||||
pin,
|
pin,
|
||||||
unpin,
|
unpin,
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ describe("applyGlobalEvent", () => {
|
|||||||
expect(refreshCount).toBe(1)
|
expect(refreshCount).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("leaves server.connected refresh to the connection sync", () => {
|
test("handles server.connected by triggering refresh", () => {
|
||||||
let refreshCount = 0
|
let refreshCount = 0
|
||||||
applyGlobalEvent({
|
applyGlobalEvent({
|
||||||
event: { type: "server.connected" },
|
event: { type: "server.connected" },
|
||||||
@@ -131,7 +131,7 @@ describe("applyGlobalEvent", () => {
|
|||||||
setGlobalProject() {},
|
setGlobalProject() {},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(refreshCount).toBe(0)
|
expect(refreshCount).toBe(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
|
|
||||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type { Message, Part, Project, Todo } from "@/types"
|
import type { Message, Part, Project, Todo } from "@/types"
|
||||||
import type {
|
import type {
|
||||||
@@ -38,10 +37,11 @@ export function applyGlobalEvent(input: {
|
|||||||
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
|
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
|
||||||
refresh: () => void
|
refresh: () => void
|
||||||
}) {
|
}) {
|
||||||
if (input.event.type === "global.disposed") {
|
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
|
||||||
input.refresh()
|
input.refresh()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.event.type !== "project.updated") return
|
if (input.event.type !== "project.updated") return
|
||||||
const properties = input.event.properties as Project
|
const properties = input.event.properties as Project
|
||||||
const result = Binary.search(input.project, properties.id, (s) => s.id)
|
const result = Binary.search(input.project, properties.id, (s) => s.id)
|
||||||
@@ -187,18 +187,6 @@ export function applyDirectoryEvent(input: {
|
|||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "project.directory.resolved": {
|
|
||||||
const properties = event.properties as { projectID: string; directory: string; previous: string }
|
|
||||||
input.store.session.forEach((session, index) => {
|
|
||||||
const adopted = ProjectDirectories.adopt(
|
|
||||||
{ projectID: session.projectID, directory: session.location.directory },
|
|
||||||
properties,
|
|
||||||
)
|
|
||||||
if (!adopted) return
|
|
||||||
input.setStore("session", index, (current) => ({ ...current, ...adopted }))
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "session.renamed": {
|
case "session.renamed": {
|
||||||
const properties = event.properties as { sessionID: string; title: string }
|
const properties = event.properties as { sessionID: string; title: string }
|
||||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Message, Part, Todo } from "@/types"
|
import type { Message, Part, Todo } from "@/types"
|
||||||
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||||
|
|
||||||
@@ -34,7 +34,6 @@ describe("app session cache", () => {
|
|||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
form: Record<string, FormInfo[] | undefined>
|
|
||||||
part_text_accum_delta: Record<string, string | undefined>
|
part_text_accum_delta: Record<string, string | undefined>
|
||||||
} = {
|
} = {
|
||||||
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
||||||
@@ -45,7 +44,6 @@ describe("app session cache", () => {
|
|||||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||||
permission: { ses_1: [] as PermissionRequest[] },
|
permission: { ses_1: [] as PermissionRequest[] },
|
||||||
question: { ses_1: [] as QuestionRequest[] },
|
question: { ses_1: [] as QuestionRequest[] },
|
||||||
form: { ses_1: [] as FormInfo[] },
|
|
||||||
part_text_accum_delta: { prt_1: "streamed text" },
|
part_text_accum_delta: { prt_1: "streamed text" },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +57,6 @@ describe("app session cache", () => {
|
|||||||
expect(store.session_status.ses_1).toBeUndefined()
|
expect(store.session_status.ses_1).toBeUndefined()
|
||||||
expect(store.permission.ses_1).toBeUndefined()
|
expect(store.permission.ses_1).toBeUndefined()
|
||||||
expect(store.question.ses_1).toBeUndefined()
|
expect(store.question.ses_1).toBeUndefined()
|
||||||
expect(store.form.ses_1).toBeUndefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("dropSessionCaches clears message-backed parts", () => {
|
test("dropSessionCaches clears message-backed parts", () => {
|
||||||
@@ -73,7 +70,6 @@ describe("app session cache", () => {
|
|||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
form: Record<string, FormInfo[] | undefined>
|
|
||||||
part_text_accum_delta: Record<string, string | undefined>
|
part_text_accum_delta: Record<string, string | undefined>
|
||||||
} = {
|
} = {
|
||||||
session_status: {},
|
session_status: {},
|
||||||
@@ -84,7 +80,6 @@ describe("app session cache", () => {
|
|||||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
question: {},
|
||||||
form: {},
|
|
||||||
part_text_accum_delta: {},
|
part_text_accum_delta: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Message, Part, Todo } from "@/types"
|
import type { Message, Part, Todo } from "@/types"
|
||||||
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
@@ -14,7 +14,6 @@ type SessionCache = {
|
|||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
form?: Record<string, FormInfo[] | undefined>
|
|
||||||
part_text_accum_delta: Record<string, string | undefined>
|
part_text_accum_delta: Record<string, string | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +38,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
|||||||
delete store.session_status[sessionID]
|
delete store.session_status[sessionID]
|
||||||
delete store.permission[sessionID]
|
delete store.permission[sessionID]
|
||||||
delete store.question[sessionID]
|
delete store.question[sessionID]
|
||||||
if (store.form) delete store.form[sessionID]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useServerHealth } from "@/utils/server-health"
|
|||||||
import { createServerSdkContext } from "./server-sdk"
|
import { createServerSdkContext } from "./server-sdk"
|
||||||
import { createServerSyncContext } from "./server-sync"
|
import { createServerSyncContext } from "./server-sync"
|
||||||
import { getOwner } from "solid-js/web"
|
import { getOwner } from "solid-js/web"
|
||||||
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||||
@@ -97,6 +98,15 @@ function createServerCtx(
|
|||||||
scope: ServerScope,
|
scope: ServerScope,
|
||||||
projects: ReturnType<typeof createServerProjects>,
|
projects: ReturnType<typeof createServerProjects>,
|
||||||
) {
|
) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
const sdk = createServerSdkContext(conn, scope)
|
const sdk = createServerSdkContext(conn, scope)
|
||||||
const sync = createServerSyncContext(sdk)
|
const sync = createServerSyncContext(sdk)
|
||||||
|
|
||||||
@@ -131,6 +141,7 @@ function createServerCtx(
|
|||||||
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
queryClient,
|
||||||
sdk,
|
sdk,
|
||||||
sync,
|
sync,
|
||||||
isLocal,
|
isLocal,
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ type PluralKey =
|
|||||||
| "session.question.pending"
|
| "session.question.pending"
|
||||||
| "session.followupDock.summary"
|
| "session.followupDock.summary"
|
||||||
| "session.revertDock.summary"
|
| "session.revertDock.summary"
|
||||||
| "session.background.shell"
|
|
||||||
| "session.background.subagent"
|
|
||||||
type Source = { dict: Record<string, string> }
|
type Source = { dict: Record<string, string> }
|
||||||
|
|
||||||
function cookie(locale: Locale) {
|
function cookie(locale: Locale) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createMemo, createResource } from "solid-js"
|
import { type Accessor, createMemo, createResource } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||||
@@ -25,8 +25,8 @@ function modelKey(model: ModelKey) {
|
|||||||
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
|
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
|
||||||
name: "Models",
|
name: "Models",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: (props: { directory?: string } = {}) => {
|
init: (props: { directory?: Accessor<string | undefined> } = {}) => {
|
||||||
const providers = useProviders(() => props.directory)
|
const providers = useProviders(() => props.directory?.())
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("model", ["model.v1"]),
|
Persist.global("model", ["model.v1"]),
|
||||||
|
|||||||
@@ -398,20 +398,15 @@ function createServerNotificationState(input: {
|
|||||||
|
|
||||||
const unsub = serverSDK().event.listen((e) => {
|
const unsub = serverSDK().event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (
|
if (event.type !== "session.idle" && event.type !== "session.execution.failed") return
|
||||||
event.type !== "session.execution.succeeded" &&
|
|
||||||
event.type !== "session.execution.interrupted" &&
|
|
||||||
event.type !== "session.execution.failed"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const time = Date.now()
|
const time = Date.now()
|
||||||
if (event.type === "session.execution.failed") {
|
if (event.type === "session.idle") {
|
||||||
handleSessionError(directory, event, time)
|
handleSessionIdle(directory, event, time)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleSessionIdle(directory, event, time)
|
handleSessionError(directory, event, time)
|
||||||
})
|
})
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
meta.disposed = true
|
meta.disposed = true
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ export type { DirectorySDK } from "./server-sdk"
|
|||||||
const context = createSimpleContext({
|
const context = createSimpleContext({
|
||||||
name: "SDK",
|
name: "SDK",
|
||||||
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
|
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
|
||||||
init: (props: { directory: string }) => {
|
init: (props: { directory: string | Accessor<string> }) => {
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
return createMemo(() => serverSDK().ensureDirSdkContext(props.directory))
|
return createMemo(() => {
|
||||||
|
const directory = typeof props.directory === "function" ? props.directory() : props.directory
|
||||||
|
return serverSDK().ensureDirSdkContext(directory)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
|||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { createApiForServer, type ServerApi } from "@/utils/server"
|
import { createApiForServer, type ServerApi } from "@/utils/server"
|
||||||
import { useLanguage } from "./language"
|
import { useLanguage } from "./language"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
@@ -13,6 +12,10 @@ import { createRefCountMap } from "@/utils/refcount"
|
|||||||
import { useGlobal } from "./global"
|
import { useGlobal } from "./global"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
|
const isAbortError = (error: unknown) =>
|
||||||
|
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||||
|
|
||||||
|
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||||
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
||||||
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
||||||
type CurrentDelta = Extract<
|
type CurrentDelta = Extract<
|
||||||
@@ -92,20 +95,15 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
||||||
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
|
|
||||||
type ServerSDKBase = {
|
type ServerSDKBase = {
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
url: string
|
url: string
|
||||||
api: ServerApi
|
api: ServerApi
|
||||||
connection: {
|
|
||||||
status: Accessor<ServerConnectionStatus>
|
|
||||||
attempt: Accessor<number>
|
|
||||||
error: Accessor<string | undefined>
|
|
||||||
}
|
|
||||||
event: {
|
event: {
|
||||||
on: ServerEventEmitter["on"]
|
on: ServerEventEmitter["on"]
|
||||||
listen: ServerEventEmitter["listen"]
|
listen: ServerEventEmitter["listen"]
|
||||||
|
start: () => Promise<void> | undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,15 +130,14 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
type Queued = QueuedServerEvent
|
type Queued = QueuedServerEvent
|
||||||
const FLUSH_FRAME_MS = 16
|
const FLUSH_FRAME_MS = 16
|
||||||
const STREAM_YIELD_MS = 8
|
const STREAM_YIELD_MS = 8
|
||||||
const CONNECT_TIMEOUT_MS = 2_000
|
const RECONNECT_DELAY_MS = 250
|
||||||
const RECONNECT_DELAY_MS = 1_000
|
|
||||||
|
|
||||||
let queue: Queued[] = []
|
let queue: Queued[] = []
|
||||||
let buffer: Queued[] = []
|
let buffer: Queued[] = []
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
let last = 0
|
let last = 0
|
||||||
|
|
||||||
function flush() {
|
const flush = () => {
|
||||||
if (timer) clearTimeout(timer)
|
if (timer) clearTimeout(timer)
|
||||||
timer = undefined
|
timer = undefined
|
||||||
|
|
||||||
@@ -160,132 +157,63 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
buffer.length = 0
|
buffer.length = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedule() {
|
const schedule = () => {
|
||||||
if (timer) return
|
if (timer) return
|
||||||
const elapsed = Date.now() - last
|
const elapsed = Date.now() - last
|
||||||
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
|
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
|
||||||
}
|
}
|
||||||
|
|
||||||
function publish(event: OpenCodeEvent) {
|
let streamErrorLogged = false
|
||||||
const directory = event.location?.directory ?? "global"
|
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||||
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
|
|
||||||
}
|
|
||||||
|
|
||||||
function wait(delay: number, signal: AbortSignal) {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
const timer = setTimeout(done, delay)
|
|
||||||
signal.addEventListener("abort", done, { once: true })
|
|
||||||
function done() {
|
|
||||||
clearTimeout(timer)
|
|
||||||
signal.removeEventListener("abort", done)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
let attempt: AbortController | undefined
|
let attempt: AbortController | undefined
|
||||||
let run: Promise<void> | undefined
|
let run: Promise<void> | undefined
|
||||||
let started = false
|
let started = false
|
||||||
let generation = 0
|
let generation = 0
|
||||||
const [connection, setConnection] = createStore<{
|
|
||||||
status: ServerConnectionStatus
|
|
||||||
attempt: number
|
|
||||||
error?: string
|
|
||||||
}>({ status: "connecting", attempt: 0 })
|
|
||||||
|
|
||||||
async function connect(signal: AbortSignal): Promise<{ error: unknown; connectedAt: number | undefined }> {
|
const start = () => {
|
||||||
let connectedAt: number | undefined
|
|
||||||
|
|
||||||
// Bound the initial handshake and tie this request to the stream lifetime.
|
|
||||||
const request = new AbortController()
|
|
||||||
const cancel = () => request.abort(signal.reason)
|
|
||||||
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), CONNECT_TIMEOUT_MS)
|
|
||||||
signal.addEventListener("abort", cancel, { once: true })
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Open the event stream and validate its initial handshake.
|
|
||||||
const iterator = eventApi.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
|
|
||||||
const first = await iterator.next()
|
|
||||||
|
|
||||||
if (signal.aborted) return { error: undefined, connectedAt }
|
|
||||||
if (first.done) {
|
|
||||||
const error =
|
|
||||||
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
|
|
||||||
return { error, connectedAt }
|
|
||||||
}
|
|
||||||
if (first.value.type !== "server.connected")
|
|
||||||
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
|
|
||||||
|
|
||||||
// Publish the connected state before forwarding live events.
|
|
||||||
clearTimeout(timeout)
|
|
||||||
publish(first.value)
|
|
||||||
connectedAt = Date.now()
|
|
||||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
|
||||||
|
|
||||||
// Forward events until the stream closes or this connection is cancelled.
|
|
||||||
let yielded = Date.now()
|
|
||||||
while (!signal.aborted) {
|
|
||||||
const event = await iterator.next()
|
|
||||||
if (signal.aborted) return { error: undefined, connectedAt }
|
|
||||||
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
|
||||||
publish(event.value)
|
|
||||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
|
||||||
yielded = Date.now()
|
|
||||||
await wait(0, signal)
|
|
||||||
}
|
|
||||||
return { error: undefined, connectedAt }
|
|
||||||
} catch (error) {
|
|
||||||
return { error, connectedAt }
|
|
||||||
} finally {
|
|
||||||
request.abort()
|
|
||||||
clearTimeout(timeout)
|
|
||||||
signal.removeEventListener("abort", cancel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runStream(active: number) {
|
|
||||||
let retries = 0
|
|
||||||
// oxlint-disable-next-line no-unmodified-loop-condition -- stop() changes the lifecycle flags and aborts the active request
|
|
||||||
while (!abort.signal.aborted && started && generation === active) {
|
|
||||||
setConnection({ status: retries === 0 ? "connecting" : "reconnecting", attempt: retries, error: undefined })
|
|
||||||
const controller = new AbortController()
|
|
||||||
attempt = controller
|
|
||||||
const onAbort = () => controller.abort()
|
|
||||||
abort.signal.addEventListener("abort", onAbort)
|
|
||||||
const result = await connect(controller.signal)
|
|
||||||
abort.signal.removeEventListener("abort", onAbort)
|
|
||||||
|
|
||||||
if (abort.signal.aborted || !started || generation !== active) {
|
|
||||||
if (attempt === controller) attempt = undefined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) retries = 0
|
|
||||||
retries += 1
|
|
||||||
const message =
|
|
||||||
result.error === undefined
|
|
||||||
? undefined
|
|
||||||
: result.error instanceof Error
|
|
||||||
? result.error.message
|
|
||||||
: String(result.error)
|
|
||||||
console.info("[global-sdk] event stream disconnected", {
|
|
||||||
url: server.http.url,
|
|
||||||
fetch: eventFetch ? "platform" : "webview",
|
|
||||||
attempt: retries,
|
|
||||||
error: message,
|
|
||||||
})
|
|
||||||
setConnection({ status: "reconnecting", attempt: retries, error: message })
|
|
||||||
await wait(RECONNECT_DELAY_MS, controller.signal)
|
|
||||||
if (attempt === controller) attempt = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function start() {
|
|
||||||
if (started) return run
|
if (started) return run
|
||||||
started = true
|
started = true
|
||||||
const active = ++generation
|
const active = ++generation
|
||||||
const previous = run
|
const previous = run
|
||||||
const current = (async () => {
|
const current = (async () => {
|
||||||
if (previous) await previous
|
if (previous) await previous
|
||||||
await runStream(active)
|
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
||||||
|
while (!abort.signal.aborted && started && generation === active) {
|
||||||
|
attempt = new AbortController()
|
||||||
|
const onAbort = () => {
|
||||||
|
attempt?.abort()
|
||||||
|
}
|
||||||
|
abort.signal.addEventListener("abort", onAbort)
|
||||||
|
try {
|
||||||
|
const events = eventApi.event.subscribe({ signal: attempt.signal })
|
||||||
|
let yielded = Date.now()
|
||||||
|
for await (const event of events) {
|
||||||
|
streamErrorLogged = false
|
||||||
|
const directory = event.location?.directory ?? "global"
|
||||||
|
const payload = adaptServerEvent(event)
|
||||||
|
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||||
|
|
||||||
|
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||||
|
yielded = Date.now()
|
||||||
|
await wait(0)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) {
|
||||||
|
streamErrorLogged = true
|
||||||
|
console.error("[global-sdk] event stream failed", {
|
||||||
|
url: server.http.url,
|
||||||
|
fetch: eventFetch ? "platform" : "webview",
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
abort.signal.removeEventListener("abort", onAbort)
|
||||||
|
attempt = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abort.signal.aborted || !started || generation !== active) return
|
||||||
|
await wait(RECONNECT_DELAY_MS)
|
||||||
|
}
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
if (run !== current) return
|
if (run !== current) return
|
||||||
run = undefined
|
run = undefined
|
||||||
@@ -295,7 +223,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
return run
|
return run
|
||||||
}
|
}
|
||||||
|
|
||||||
function stop() {
|
const stop = () => {
|
||||||
started = false
|
started = false
|
||||||
generation++
|
generation++
|
||||||
attempt?.abort()
|
attempt?.abort()
|
||||||
@@ -304,17 +232,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
makeEventListener(window, "pagehide", stop)
|
makeEventListener(window, "pagehide", stop)
|
||||||
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
|
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
|
||||||
void start()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
stop()
|
stop()
|
||||||
abort.abort()
|
abort.abort()
|
||||||
if (timer) clearTimeout(timer)
|
flush()
|
||||||
timer = undefined
|
|
||||||
queue = []
|
|
||||||
buffer = []
|
|
||||||
emitter.clear()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||||
@@ -324,14 +247,10 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
scope,
|
scope,
|
||||||
url: server.http.url,
|
url: server.http.url,
|
||||||
api,
|
api,
|
||||||
connection: {
|
|
||||||
status: () => connection.status,
|
|
||||||
attempt: () => connection.attempt,
|
|
||||||
error: () => connection.error,
|
|
||||||
},
|
|
||||||
event: {
|
event: {
|
||||||
on: emitter.on.bind(emitter),
|
on: emitter.on.bind(emitter),
|
||||||
listen: emitter.listen.bind(emitter),
|
listen: emitter.listen.bind(emitter),
|
||||||
|
start,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,13 +270,13 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
|||||||
name: "ServerSDK",
|
name: "ServerSDK",
|
||||||
// Returns an accessor so the resolved server can change reactively (e.g. a
|
// Returns an accessor so the resolved server can change reactively (e.g. a
|
||||||
// /new-session draft retargeting its server) without re-instantiating the subtree.
|
// /new-session draft retargeting its server) without re-instantiating the subtree.
|
||||||
init: (props: { server?: ServerConnection.Any }) => {
|
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
|
|
||||||
return createMemo<ServerSDK>(() => {
|
return createMemo<ServerSDK>(() => {
|
||||||
const conn = props.server ?? server.current
|
const conn = props.server?.() ?? server.current
|
||||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||||
return global.ensureServerCtx(conn).sdk
|
return global.ensureServerCtx(conn).sdk
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,18 +18,18 @@ describe("v2 session reducer", () => {
|
|||||||
apply({
|
apply({
|
||||||
...base,
|
...base,
|
||||||
id: "evt_admitted",
|
id: "evt_admitted",
|
||||||
type: "session.inbox.enqueued",
|
type: "session.input.admitted",
|
||||||
data: {
|
data: {
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
inboxID: "msg_user",
|
inputID: "msg_user",
|
||||||
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
|
input: { type: "user", delivery: "steer", data: { text: "hello" } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
apply({
|
apply({
|
||||||
...base,
|
...base,
|
||||||
id: "evt_promoted",
|
id: "evt_promoted",
|
||||||
type: "session.inbox.delivered",
|
type: "session.input.promoted",
|
||||||
data: { sessionID: "ses_1", inboxID: "msg_user" },
|
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||||
})
|
})
|
||||||
apply({
|
apply({
|
||||||
...base,
|
...base,
|
||||||
@@ -203,66 +203,14 @@ describe("v2 session reducer", () => {
|
|||||||
event({
|
event({
|
||||||
...base,
|
...base,
|
||||||
id: "evt_promoted",
|
id: "evt_promoted",
|
||||||
type: "session.inbox.delivered",
|
type: "session.input.promoted",
|
||||||
data: { sessionID: "ses_1", inboxID: "msg_user" },
|
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("projects rendered instruction updates", () => {
|
|
||||||
const reducer = createV2SessionReducer()
|
|
||||||
const result = reducer.reduce(
|
|
||||||
[],
|
|
||||||
event({
|
|
||||||
...base,
|
|
||||||
id: "evt_instructions",
|
|
||||||
type: "session.instructions.updated",
|
|
||||||
data: { sessionID: "ses_1", delta: { agents: "hash" }, text: "Changed instructions" },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result?.messages).toEqual([
|
|
||||||
{
|
|
||||||
id: "msg_instructions",
|
|
||||||
type: "system",
|
|
||||||
text: "Changed instructions",
|
|
||||||
description: "Instructions updated: agents",
|
|
||||||
metadata: undefined,
|
|
||||||
time: { created: 1 },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("projects session movement with the previous location", () => {
|
|
||||||
const result = createV2SessionReducer().reduce(
|
|
||||||
[],
|
|
||||||
event({
|
|
||||||
...base,
|
|
||||||
id: "evt_moved",
|
|
||||||
type: "session.moved",
|
|
||||||
data: {
|
|
||||||
sessionID: "ses_1",
|
|
||||||
projectID: "project_2",
|
|
||||||
location: { directory: "/repo-2" },
|
|
||||||
subpath: "packages/app",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{ projectID: "project_1", location: { directory: "/repo-1" } },
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result?.messages).toMatchObject([
|
|
||||||
{
|
|
||||||
id: "msg_moved",
|
|
||||||
type: "location-switched",
|
|
||||||
projectID: "project_2",
|
|
||||||
location: { directory: "/repo-2" },
|
|
||||||
previous: { projectID: "project_1", location: { directory: "/repo-1" } },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("removes cancelled input from the pending promotion fold", () => {
|
test("removes cancelled input from the pending promotion fold", () => {
|
||||||
const reducer = createV2SessionReducer()
|
const reducer = createV2SessionReducer()
|
||||||
reducer.reduce(
|
reducer.reduce(
|
||||||
@@ -270,11 +218,11 @@ describe("v2 session reducer", () => {
|
|||||||
event({
|
event({
|
||||||
...base,
|
...base,
|
||||||
id: "evt_admitted",
|
id: "evt_admitted",
|
||||||
type: "session.inbox.enqueued",
|
type: "session.input.admitted",
|
||||||
data: {
|
data: {
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
inboxID: "msg_user",
|
inputID: "msg_user",
|
||||||
item: { type: "user", delivery: "queue", payload: { text: "cancel me" } },
|
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -283,8 +231,8 @@ describe("v2 session reducer", () => {
|
|||||||
event({
|
event({
|
||||||
...base,
|
...base,
|
||||||
id: "evt_cancelled",
|
id: "evt_cancelled",
|
||||||
type: "session.inbox.cancelled",
|
type: "session.input.cancelled",
|
||||||
data: { sessionID: "ses_1", inboxID: "msg_user" },
|
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const result = reducer.reduce(
|
const result = reducer.reduce(
|
||||||
@@ -292,8 +240,8 @@ describe("v2 session reducer", () => {
|
|||||||
event({
|
event({
|
||||||
...base,
|
...base,
|
||||||
id: "evt_promoted",
|
id: "evt_promoted",
|
||||||
type: "session.inbox.delivered",
|
type: "session.input.promoted",
|
||||||
data: { sessionID: "ses_1", inboxID: "msg_user" },
|
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -307,11 +255,11 @@ describe("v2 session reducer", () => {
|
|||||||
event({
|
event({
|
||||||
...base,
|
...base,
|
||||||
id: "evt_admitted",
|
id: "evt_admitted",
|
||||||
type: "session.inbox.enqueued",
|
type: "session.input.admitted",
|
||||||
data: {
|
data: {
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
inboxID: "msg_user",
|
inputID: "msg_user",
|
||||||
item: { type: "user", delivery: "queue", payload: { text: "steer me" } },
|
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -320,8 +268,8 @@ describe("v2 session reducer", () => {
|
|||||||
event({
|
event({
|
||||||
...base,
|
...base,
|
||||||
id: "evt_steered",
|
id: "evt_steered",
|
||||||
type: "session.inbox.delivery.changed",
|
type: "session.input.steered",
|
||||||
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "steer" },
|
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
reducer.reduce(
|
reducer.reduce(
|
||||||
@@ -329,8 +277,8 @@ describe("v2 session reducer", () => {
|
|||||||
event({
|
event({
|
||||||
...base,
|
...base,
|
||||||
id: "evt_queued",
|
id: "evt_queued",
|
||||||
type: "session.inbox.delivery.changed",
|
type: "session.input.queued",
|
||||||
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "queue" },
|
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -339,8 +287,8 @@ describe("v2 session reducer", () => {
|
|||||||
event({
|
event({
|
||||||
...base,
|
...base,
|
||||||
id: "evt_promoted",
|
id: "evt_promoted",
|
||||||
type: "session.inbox.delivered",
|
type: "session.input.promoted",
|
||||||
data: { sessionID: "ses_1", inboxID: "msg_user" },
|
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { OpenCodeEvent, SessionInboxItem, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
|
import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
|
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
|
||||||
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
|
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
|
||||||
@@ -8,18 +8,13 @@ export type V2SessionReduction = {
|
|||||||
sessionID: string
|
sessionID: string
|
||||||
messages: SessionMessageInfo[]
|
messages: SessionMessageInfo[]
|
||||||
touched: string[]
|
touched: string[]
|
||||||
removed?: string[]
|
|
||||||
missing?: string
|
missing?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createV2SessionReducer() {
|
export function createV2SessionReducer() {
|
||||||
const pending = new Map<string, SessionInboxItem>()
|
const pending = new Map<string, SessionPendingMessage>()
|
||||||
|
|
||||||
const reduce = (
|
const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => {
|
||||||
source: readonly SessionMessageInfo[],
|
|
||||||
event: OpenCodeEvent,
|
|
||||||
session?: Pick<SessionInfo, "location" | "projectID" | "subpath">,
|
|
||||||
): V2SessionReduction | undefined => {
|
|
||||||
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
|
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
|
||||||
const sessionID = event.data.sessionID
|
const sessionID = event.data.sessionID
|
||||||
const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({
|
const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({
|
||||||
@@ -31,59 +26,32 @@ export function createV2SessionReducer() {
|
|||||||
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
|
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "session.inbox.enqueued":
|
case "session.input.admitted":
|
||||||
pending.set(key(sessionID, event.data.inboxID), event.data.item)
|
pending.set(key(sessionID, event.data.inputID), event.data.input)
|
||||||
if (event.data.item.type === "user")
|
return result([...source])
|
||||||
return append({
|
case "session.input.cancelled":
|
||||||
id: event.data.inboxID,
|
pending.delete(key(sessionID, event.data.inputID))
|
||||||
type: "user",
|
return
|
||||||
metadata: event.data.item.payload.metadata,
|
case "session.input.promoted": {
|
||||||
text: event.data.item.payload.text,
|
const input = pending.get(key(sessionID, event.data.inputID))
|
||||||
files: event.data.item.payload.files,
|
pending.delete(key(sessionID, event.data.inputID))
|
||||||
agents: event.data.item.payload.agents,
|
if (!input) return { ...result([...source]), missing: event.data.inputID }
|
||||||
time: { created: event.created },
|
|
||||||
})
|
|
||||||
if (event.data.item.type !== "synthetic") return result([...source])
|
|
||||||
return append({
|
|
||||||
id: event.data.inboxID,
|
|
||||||
type: "synthetic",
|
|
||||||
metadata: event.data.item.payload.metadata,
|
|
||||||
text: event.data.item.payload.text,
|
|
||||||
description: event.data.item.payload.description,
|
|
||||||
time: { created: event.created },
|
|
||||||
})
|
|
||||||
case "session.inbox.cancelled":
|
|
||||||
pending.delete(key(sessionID, event.data.inboxID))
|
|
||||||
return {
|
|
||||||
...result(source.filter((item) => item.id !== event.data.inboxID)),
|
|
||||||
removed: source.some((item) => item.id === event.data.inboxID) ? [event.data.inboxID] : [],
|
|
||||||
}
|
|
||||||
case "session.inbox.delivered": {
|
|
||||||
const input = pending.get(key(sessionID, event.data.inboxID))
|
|
||||||
pending.delete(key(sessionID, event.data.inboxID))
|
|
||||||
const existing = source.find((item) => item.id === event.data.inboxID)
|
|
||||||
if (existing) {
|
|
||||||
const promoted = { ...existing, time: { ...existing.time, created: event.created } }
|
|
||||||
return result([...source.filter((item) => item.id !== existing.id), promoted], [existing.id])
|
|
||||||
}
|
|
||||||
if (!input) return { ...result([...source]), missing: event.data.inboxID }
|
|
||||||
if (input.type === "user")
|
if (input.type === "user")
|
||||||
return append({
|
return append({
|
||||||
id: event.data.inboxID,
|
id: event.data.inputID,
|
||||||
type: "user",
|
type: "user",
|
||||||
metadata: input.payload.metadata,
|
metadata: input.data.metadata,
|
||||||
text: input.payload.text,
|
text: input.data.text,
|
||||||
files: input.payload.files,
|
files: input.data.files,
|
||||||
agents: input.payload.agents,
|
agents: input.data.agents,
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
})
|
})
|
||||||
if (input.type !== "synthetic") return result([...source])
|
|
||||||
return append({
|
return append({
|
||||||
id: event.data.inboxID,
|
id: event.data.inputID,
|
||||||
type: "synthetic",
|
type: "synthetic",
|
||||||
metadata: input.payload.metadata,
|
metadata: input.data.metadata,
|
||||||
text: input.payload.text,
|
text: input.data.text,
|
||||||
description: input.payload.description,
|
description: input.data.description,
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -115,32 +83,6 @@ export function createV2SessionReducer() {
|
|||||||
)?.model,
|
)?.model,
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
})
|
})
|
||||||
case "session.moved":
|
|
||||||
if (!session) return
|
|
||||||
return append({
|
|
||||||
id: messageID(event.id),
|
|
||||||
type: "location-switched",
|
|
||||||
metadata: event.metadata,
|
|
||||||
location: event.data.location,
|
|
||||||
projectID: event.data.projectID,
|
|
||||||
subpath: event.data.subpath,
|
|
||||||
previous: {
|
|
||||||
location: session.location,
|
|
||||||
projectID: session.projectID,
|
|
||||||
subpath: session.subpath,
|
|
||||||
},
|
|
||||||
time: { created: event.created },
|
|
||||||
})
|
|
||||||
case "session.instructions.updated":
|
|
||||||
if (event.data.text === undefined) return
|
|
||||||
return append({
|
|
||||||
id: messageID(event.id),
|
|
||||||
type: "system",
|
|
||||||
text: event.data.text,
|
|
||||||
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
|
|
||||||
metadata: event.metadata,
|
|
||||||
time: { created: event.created },
|
|
||||||
})
|
|
||||||
case "session.synthetic":
|
case "session.synthetic":
|
||||||
return append({
|
return append({
|
||||||
id: messageID(event.id),
|
id: messageID(event.id),
|
||||||
@@ -409,7 +351,7 @@ export function createV2SessionReducer() {
|
|||||||
metadata: event.metadata,
|
metadata: event.metadata,
|
||||||
reason: event.data.reason,
|
reason: event.data.reason,
|
||||||
summary: "",
|
summary: "",
|
||||||
recent: event.data.recent ?? "",
|
recent: event.data.recent,
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
})
|
})
|
||||||
case "session.compaction.delta":
|
case "session.compaction.delta":
|
||||||
@@ -460,11 +402,8 @@ export function createV2SessionReducer() {
|
|||||||
type: "compaction",
|
type: "compaction",
|
||||||
status: "failed",
|
status: "failed",
|
||||||
metadata: current?.metadata ?? event.metadata,
|
metadata: current?.metadata ?? event.metadata,
|
||||||
reason: event.data.reason ?? "manual",
|
reason: event.data.reason,
|
||||||
error: event.data.error ?? {
|
error: event.data.error,
|
||||||
type: "compaction.failed",
|
|
||||||
message: "Compaction failed before recording an error",
|
|
||||||
},
|
|
||||||
time: current?.time ?? { created: event.created },
|
time: current?.time ?? { created: event.created },
|
||||||
}
|
}
|
||||||
if (!current) return append(failed)
|
if (!current) return append(failed)
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { retry } from "@opencode-ai/core/util/retry"
|
import type { retry } from "@opencode-ai/core/util/retry"
|
||||||
import type {
|
import type {
|
||||||
FormInfo,
|
|
||||||
OpenCodeEvent,
|
OpenCodeEvent,
|
||||||
SessionApi,
|
SessionApi,
|
||||||
SessionInboxInfo,
|
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
SessionMessageAssistant,
|
SessionMessageAssistant,
|
||||||
SessionMessageAssistantTool,
|
SessionMessageAssistantTool,
|
||||||
@@ -350,187 +348,6 @@ describe("server session", () => {
|
|||||||
expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
|
expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("projects V2 pending inputs and forms", () => {
|
|
||||||
const ctx = setup({ child: session("child") })
|
|
||||||
const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent)
|
|
||||||
|
|
||||||
apply({
|
|
||||||
id: "evt_admitted",
|
|
||||||
created: 1,
|
|
||||||
type: "session.inbox.enqueued",
|
|
||||||
data: {
|
|
||||||
sessionID: "child",
|
|
||||||
inboxID: "msg_input",
|
|
||||||
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
apply({
|
|
||||||
id: "evt_form",
|
|
||||||
created: 2,
|
|
||||||
type: "form.created",
|
|
||||||
data: { form: { id: "frm_1", sessionID: "child", title: "Choose", fields: [] } },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(ctx.store.data.pending.child).toMatchObject([{ id: "msg_input", delivery: "steer" }])
|
|
||||||
expect(ctx.store.data.input.child).toEqual(["msg_input"])
|
|
||||||
expect(ctx.store.data.form.child).toMatchObject([{ id: "frm_1", title: "Choose" }])
|
|
||||||
expect(ctx.store.data.message.child).toMatchObject([{ id: "msg_input" }])
|
|
||||||
|
|
||||||
apply({
|
|
||||||
id: "evt_cancelled",
|
|
||||||
created: 3,
|
|
||||||
type: "session.inbox.cancelled",
|
|
||||||
data: { sessionID: "child", inboxID: "msg_input" },
|
|
||||||
})
|
|
||||||
apply({
|
|
||||||
id: "evt_form_done",
|
|
||||||
created: 4,
|
|
||||||
type: "form.cancelled",
|
|
||||||
data: { sessionID: "child", id: "frm_1" },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(ctx.store.data.pending.child).toEqual([])
|
|
||||||
expect(ctx.store.data.input.child).toEqual([])
|
|
||||||
expect(ctx.store.data.form.child).toEqual([])
|
|
||||||
expect(ctx.store.data.message.child).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not let transient hydration overwrite newer events", async () => {
|
|
||||||
const ctx = setup({ child: session("child") })
|
|
||||||
const response = Promise.withResolvers<{ pending: SessionInboxInfo[]; forms: FormInfo[] }>()
|
|
||||||
const form: FormInfo = {
|
|
||||||
id: "frm_1",
|
|
||||||
sessionID: "child",
|
|
||||||
title: "Choose",
|
|
||||||
fields: [{ key: "choice", type: "string" as const, title: "Choice" }],
|
|
||||||
}
|
|
||||||
let loads = 0
|
|
||||||
const hydration = ctx.store.hydrateTransient("child", () => {
|
|
||||||
loads += 1
|
|
||||||
if (loads === 1) return response.promise
|
|
||||||
return Promise.resolve({ pending: ctx.store.data.pending.child ?? [], forms: [form] })
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx.store.applyV2({
|
|
||||||
id: "evt_admitted",
|
|
||||||
created: 1,
|
|
||||||
type: "session.inbox.enqueued",
|
|
||||||
data: {
|
|
||||||
sessionID: "child",
|
|
||||||
inboxID: "msg_input",
|
|
||||||
item: { type: "user", delivery: "queue", payload: { text: "new" } },
|
|
||||||
},
|
|
||||||
} as OpenCodeEvent)
|
|
||||||
response.resolve({
|
|
||||||
pending: [],
|
|
||||||
forms: [form],
|
|
||||||
})
|
|
||||||
await hydration
|
|
||||||
|
|
||||||
expect(ctx.store.data.pending.child).toMatchObject([{ id: "msg_input" }])
|
|
||||||
expect(ctx.store.data.form.child).toMatchObject([{ id: "frm_1" }])
|
|
||||||
expect(loads).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("removes only the compaction input named by the start event", () => {
|
|
||||||
const ctx = setup({ child: session("child") })
|
|
||||||
const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent)
|
|
||||||
apply({
|
|
||||||
id: "evt_compaction_inbox",
|
|
||||||
created: 1,
|
|
||||||
type: "session.inbox.enqueued",
|
|
||||||
data: {
|
|
||||||
sessionID: "child",
|
|
||||||
inboxID: "msg_compaction",
|
|
||||||
item: { type: "compaction", delivery: "queue", payload: { reason: "manual" } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(ctx.store.data.input.child).toBeUndefined()
|
|
||||||
expect(ctx.store.data.pending.child).toHaveLength(1)
|
|
||||||
|
|
||||||
apply({
|
|
||||||
id: "evt_compaction_inbox_2",
|
|
||||||
created: 2,
|
|
||||||
type: "session.inbox.enqueued",
|
|
||||||
data: {
|
|
||||||
sessionID: "child",
|
|
||||||
inboxID: "msg_compaction_2",
|
|
||||||
item: { type: "compaction", delivery: "queue", payload: { reason: "manual" } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
apply({
|
|
||||||
id: "evt_compaction_ended",
|
|
||||||
created: 3,
|
|
||||||
type: "session.compaction.ended",
|
|
||||||
data: { sessionID: "child", reason: "manual", text: "summary", recent: "recent" },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(ctx.store.data.pending.child).toHaveLength(2)
|
|
||||||
|
|
||||||
apply({
|
|
||||||
id: "evt_compaction_started",
|
|
||||||
created: 4,
|
|
||||||
type: "session.compaction.started",
|
|
||||||
data: { sessionID: "child", inputID: "msg_compaction", reason: "manual" },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(ctx.store.data.pending.child?.map((item) => item.id)).toEqual(["msg_compaction_2"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("projects committed revert before server reconciliation", () => {
|
|
||||||
const ctx = setup({ child: session("child") })
|
|
||||||
ctx.store.remember({ ...session("child"), revert: { messageID: "msg_2", partID: "prt_1" } })
|
|
||||||
ctx.store.set("input", "child", ["msg_1", "msg_2"])
|
|
||||||
ctx.store.set("session_message", "child", [
|
|
||||||
{ id: "msg_1", type: "user", text: "keep", time: { created: 1 } },
|
|
||||||
{ id: "msg_2", type: "user", text: "remove", time: { created: 2 } },
|
|
||||||
])
|
|
||||||
|
|
||||||
ctx.store.applyV2({
|
|
||||||
id: "evt_revert",
|
|
||||||
created: 3,
|
|
||||||
type: "session.revert.committed",
|
|
||||||
data: { sessionID: "child", to: "msg_2" },
|
|
||||||
} as OpenCodeEvent)
|
|
||||||
|
|
||||||
expect(ctx.store.data.info.child?.revert).toBeUndefined()
|
|
||||||
expect(ctx.store.data.input.child).toEqual(["msg_1"])
|
|
||||||
expect(ctx.store.data.session_message.child?.map((message) => message.id)).toEqual(["msg_1"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not restore a message hydrated before a committed revert", async () => {
|
|
||||||
const response = Promise.withResolvers<SessionMessageInfo>()
|
|
||||||
const store = createServerSession({
|
|
||||||
session: {
|
|
||||||
get: async () => session("child"),
|
|
||||||
message: () => response.promise,
|
|
||||||
} as unknown as SessionApi,
|
|
||||||
message: {
|
|
||||||
list: async () => currentPage({ data: [], response: { headers: new Headers() } }),
|
|
||||||
} as unknown as MessageApi,
|
|
||||||
})
|
|
||||||
store.remember(session("child"))
|
|
||||||
|
|
||||||
store.applyV2({
|
|
||||||
id: "evt_delivered",
|
|
||||||
created: 1,
|
|
||||||
type: "session.inbox.delivered",
|
|
||||||
data: { sessionID: "child", inboxID: "msg_2" },
|
|
||||||
} as OpenCodeEvent)
|
|
||||||
store.applyV2({
|
|
||||||
id: "evt_revert",
|
|
||||||
created: 2,
|
|
||||||
type: "session.revert.committed",
|
|
||||||
data: { sessionID: "child", to: "msg_2" },
|
|
||||||
} as OpenCodeEvent)
|
|
||||||
response.resolve({ id: "msg_2", type: "user", text: "stale", time: { created: 1 } })
|
|
||||||
await response.promise
|
|
||||||
await Bun.sleep(0)
|
|
||||||
|
|
||||||
expect(store.data.session_message.child ?? []).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("resolves lineage by session ID without directory", async () => {
|
test("resolves lineage by session ID without directory", async () => {
|
||||||
const ctx = setup({ child: session("child", "root"), root: session("root") })
|
const ctx = setup({ child: session("child", "root"), root: session("root") })
|
||||||
|
|
||||||
@@ -551,17 +368,6 @@ describe("server session", () => {
|
|||||||
expect(ctx.store.data.message.root).toEqual([])
|
expect(ctx.store.data.message.root).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("reloads cached sessions after reconnect invalidation", async () => {
|
|
||||||
const ctx = setup({ root: session("root") })
|
|
||||||
await ctx.store.sync("root")
|
|
||||||
|
|
||||||
ctx.store.invalidate()
|
|
||||||
await ctx.store.sync("root")
|
|
||||||
|
|
||||||
expect(ctx.get).toHaveLength(2)
|
|
||||||
expect(ctx.messages).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("loads current session content through the current message API", async () => {
|
test("loads current session content through the current message API", async () => {
|
||||||
const requests: unknown[] = []
|
const requests: unknown[] = []
|
||||||
const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } }
|
const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } }
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
|
|
||||||
import { retry } from "@opencode-ai/core/util/retry"
|
import { retry } from "@opencode-ai/core/util/retry"
|
||||||
import type {
|
import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
FormInfo,
|
|
||||||
OpenCodeEvent,
|
|
||||||
SessionApi,
|
|
||||||
SessionInfo,
|
|
||||||
SessionInboxInfo,
|
|
||||||
SessionMessageInfo,
|
|
||||||
} from "@opencode-ai/client/promise"
|
|
||||||
import type { Message, Part, Todo } from "@/types"
|
import type { Message, Part, Todo } from "@/types"
|
||||||
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||||
import { batch } from "solid-js"
|
import { batch } from "solid-js"
|
||||||
@@ -199,9 +191,6 @@ export function createServerSession(
|
|||||||
todo: {} as Record<string, Todo[]>,
|
todo: {} as Record<string, Todo[]>,
|
||||||
permission: {} as Record<string, PermissionRequest[]>,
|
permission: {} as Record<string, PermissionRequest[]>,
|
||||||
question: {} as Record<string, QuestionRequest[]>,
|
question: {} as Record<string, QuestionRequest[]>,
|
||||||
form: {} as Record<string, FormInfo[]>,
|
|
||||||
pending: {} as Record<string, SessionInboxInfo[]>,
|
|
||||||
input: {} as Record<string, string[]>,
|
|
||||||
message: {} as Record<string, Message[]>,
|
message: {} as Record<string, Message[]>,
|
||||||
session_message: {} as Record<string, SessionMessageInfo[]>,
|
session_message: {} as Record<string, SessionMessageInfo[]>,
|
||||||
part: {} as Record<string, Part[]>,
|
part: {} as Record<string, Part[]>,
|
||||||
@@ -215,11 +204,6 @@ export function createServerSession(
|
|||||||
const inflightTodo = new Map<string, Promise<void>>()
|
const inflightTodo = new Map<string, Promise<void>>()
|
||||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||||
const v2 = createV2SessionReducer()
|
const v2 = createV2SessionReducer()
|
||||||
const pendingRevision = new Map<string, number>()
|
|
||||||
const formRevision = new Map<string, number>()
|
|
||||||
const messageHydrationRevision = new Map<string, number>()
|
|
||||||
const invalidated = new Set<string>()
|
|
||||||
let invalidationRevision = 0
|
|
||||||
const messageLoads = new Map<string, MessageLoadState>()
|
const messageLoads = new Map<string, MessageLoadState>()
|
||||||
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
||||||
const orphanParts = new Map<string, Set<string>>()
|
const orphanParts = new Map<string, Set<string>>()
|
||||||
@@ -278,9 +262,6 @@ export function createServerSession(
|
|||||||
...Object.entries(data.question)
|
...Object.entries(data.question)
|
||||||
.filter(([, items]) => items.length > 0)
|
.filter(([, items]) => items.length > 0)
|
||||||
.map(([sessionID]) => sessionID),
|
.map(([sessionID]) => sessionID),
|
||||||
...Object.entries(data.form)
|
|
||||||
.filter(([, items]) => items.length > 0)
|
|
||||||
.map(([sessionID]) => sessionID),
|
|
||||||
...Object.entries(data.session_status)
|
...Object.entries(data.session_status)
|
||||||
.filter(([, status]) => status.type !== "idle")
|
.filter(([, status]) => status.type !== "idle")
|
||||||
.map(([sessionID]) => sessionID),
|
.map(([sessionID]) => sessionID),
|
||||||
@@ -482,7 +463,6 @@ export function createServerSession(
|
|||||||
if (evicted.has(item.sessionID)) deltaBases.delete(partID)
|
if (evicted.has(item.sessionID)) deltaBases.delete(partID)
|
||||||
}
|
}
|
||||||
sessionIDs.forEach((sessionID) => {
|
sessionIDs.forEach((sessionID) => {
|
||||||
messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1)
|
|
||||||
generations.delete(sessionID)
|
generations.delete(sessionID)
|
||||||
clearOptimistic(sessionID)
|
clearOptimistic(sessionID)
|
||||||
requests.delete(sessionID)
|
requests.delete(sessionID)
|
||||||
@@ -526,9 +506,6 @@ export function createServerSession(
|
|||||||
...Object.entries(data.question)
|
...Object.entries(data.question)
|
||||||
.filter(([, items]) => items.length > 0)
|
.filter(([, items]) => items.length > 0)
|
||||||
.map(([sessionID]) => sessionID),
|
.map(([sessionID]) => sessionID),
|
||||||
...Object.entries(data.form)
|
|
||||||
.filter(([, items]) => items.length > 0)
|
|
||||||
.map(([sessionID]) => sessionID),
|
|
||||||
...Object.entries(data.session_status)
|
...Object.entries(data.session_status)
|
||||||
.filter(([, status]) => status.type !== "idle")
|
.filter(([, status]) => status.type !== "idle")
|
||||||
.map(([sessionID]) => sessionID),
|
.map(([sessionID]) => sessionID),
|
||||||
@@ -820,16 +797,13 @@ export function createServerSession(
|
|||||||
touch(sessionID)
|
touch(sessionID)
|
||||||
return runInflight(inflight, sessionID, async () => {
|
return runInflight(inflight, sessionID, async () => {
|
||||||
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
|
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
|
||||||
const invalid = invalidated.has(sessionID)
|
if (cached && data.info[sessionID] && !options?.force) return
|
||||||
const revision = invalidationRevision
|
|
||||||
if (cached && data.info[sessionID] && !invalid && !options?.force) return
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
resolve(sessionID, invalid ? { ...options, force: true } : options),
|
resolve(sessionID, options),
|
||||||
cached && !invalid && !options?.force
|
cached && !options?.force
|
||||||
? Promise.resolve()
|
? Promise.resolve()
|
||||||
: loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize),
|
: loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize),
|
||||||
])
|
])
|
||||||
if (invalid && invalidationRevision === revision) invalidated.delete(sessionID)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,7 +843,7 @@ export function createServerSession(
|
|||||||
const projectV2 = (reduction: V2SessionReduction) => {
|
const projectV2 = (reduction: V2SessionReduction) => {
|
||||||
reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID))
|
reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID))
|
||||||
setData("session_message", reduction.sessionID, reconcile(reduction.messages))
|
setData("session_message", reduction.sessionID, reconcile(reduction.messages))
|
||||||
if (reduction.touched.length === 0 && !reduction.removed?.length) return
|
if (reduction.touched.length === 0) return
|
||||||
|
|
||||||
const touched = new Set(reduction.touched)
|
const touched = new Set(reduction.touched)
|
||||||
let parentID: string | undefined
|
let parentID: string | undefined
|
||||||
@@ -886,9 +860,6 @@ export function createServerSession(
|
|||||||
|
|
||||||
const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages)
|
const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages)
|
||||||
batch(() => {
|
batch(() => {
|
||||||
for (const messageID of reduction.removed ?? []) {
|
|
||||||
apply({ type: "message.removed", properties: { sessionID: reduction.sessionID, messageID } })
|
|
||||||
}
|
|
||||||
for (const message of normalized.messages) {
|
for (const message of normalized.messages) {
|
||||||
if (!touched.has(message.id)) continue
|
if (!touched.has(message.id)) continue
|
||||||
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
|
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
|
||||||
@@ -912,14 +883,9 @@ export function createServerSession(
|
|||||||
|
|
||||||
const hydrateV2Message = (sessionID: string, messageID: string) => {
|
const hydrateV2Message = (sessionID: string, messageID: string) => {
|
||||||
if (!sessionApi) return
|
if (!sessionApi) return
|
||||||
const active = generation(sessionID)
|
|
||||||
const revision = messageHydrationRevision.get(sessionID) ?? 0
|
|
||||||
void sessionApi
|
void sessionApi
|
||||||
.message({ sessionID, messageID })
|
.message({ sessionID, messageID })
|
||||||
.then((message) => {
|
.then((message) => {
|
||||||
if (generations.get(sessionID) !== active) return
|
|
||||||
if ((messageHydrationRevision.get(sessionID) ?? 0) !== revision) return
|
|
||||||
if (removedMessages.get(sessionID)?.has(message.id)) return
|
|
||||||
const current = data.session_message[sessionID] ?? []
|
const current = data.session_message[sessionID] ?? []
|
||||||
const messages = [...current.filter((item) => item.id !== message.id), message].sort(compareMessages)
|
const messages = [...current.filter((item) => item.id !== message.id), message].sort(compareMessages)
|
||||||
projectV2({ sessionID, messages, touched: [message.id] })
|
projectV2({ sessionID, messages, touched: [message.id] })
|
||||||
@@ -928,86 +894,21 @@ export function createServerSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const applyV2 = (event: OpenCodeEvent) => {
|
const applyV2 = (event: OpenCodeEvent) => {
|
||||||
if (event.type === "form.created") {
|
|
||||||
formRevision.set(event.data.form.sessionID, (formRevision.get(event.data.form.sessionID) ?? 0) + 1)
|
|
||||||
const current = data.form[event.data.form.sessionID] ?? []
|
|
||||||
if (!current.some((form) => form.id === event.data.form.id))
|
|
||||||
setData("form", event.data.form.sessionID, [...current, event.data.form])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type === "form.replied" || event.type === "form.cancelled") {
|
|
||||||
formRevision.set(event.data.sessionID, (formRevision.get(event.data.sessionID) ?? 0) + 1)
|
|
||||||
setData("form", event.data.sessionID, (forms) => forms?.filter((form) => form.id !== event.data.id))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type === "project.directory.resolved") {
|
|
||||||
Object.values(data.info).forEach((info) => {
|
|
||||||
if (!info) return
|
|
||||||
const adopted = ProjectDirectories.adopt(
|
|
||||||
{ projectID: info.projectID, directory: info.location.directory },
|
|
||||||
event.data,
|
|
||||||
)
|
|
||||||
if (adopted) remember({ ...info, ...adopted })
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
|
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
|
||||||
const sessionID = event.data.sessionID
|
const sessionID = event.data.sessionID
|
||||||
if (
|
const reduction = v2.reduce(data.session_message[sessionID] ?? [], event)
|
||||||
event.type === "session.inbox.enqueued" ||
|
|
||||||
event.type === "session.inbox.delivery.changed" ||
|
|
||||||
event.type === "session.inbox.cancelled" ||
|
|
||||||
event.type === "session.inbox.delivered" ||
|
|
||||||
event.type === "session.compaction.started" ||
|
|
||||||
event.type === "session.compaction.failed"
|
|
||||||
)
|
|
||||||
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
|
|
||||||
if (event.type === "session.inbox.enqueued") {
|
|
||||||
const current = data.pending[sessionID] ?? []
|
|
||||||
if (!current.some((item) => item.id === event.data.inboxID))
|
|
||||||
setData("pending", sessionID, [
|
|
||||||
...current,
|
|
||||||
{ id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item },
|
|
||||||
])
|
|
||||||
if (event.data.item.type !== "compaction" && !data.input[sessionID]?.includes(event.data.inboxID))
|
|
||||||
setData("input", sessionID, [...(data.input[sessionID] ?? []), event.data.inboxID])
|
|
||||||
}
|
|
||||||
if (event.type === "session.inbox.delivery.changed")
|
|
||||||
setData("pending", sessionID, (items) =>
|
|
||||||
items?.map((item) => (item.id === event.data.inboxID ? { ...item, delivery: event.data.delivery } : item)),
|
|
||||||
)
|
|
||||||
if (event.type === "session.inbox.cancelled" || event.type === "session.inbox.delivered") {
|
|
||||||
setData("pending", sessionID, (items) => items?.filter((item) => item.id !== event.data.inboxID))
|
|
||||||
setData("input", sessionID, (items) => items?.filter((id) => id !== event.data.inboxID))
|
|
||||||
}
|
|
||||||
if (event.type === "session.compaction.started" || event.type === "session.compaction.failed") {
|
|
||||||
setData("pending", sessionID, (items) => items?.filter((item) => item.id !== event.data.inputID))
|
|
||||||
setData("input", sessionID, (items) => items?.filter((id) => id !== event.data.inputID))
|
|
||||||
}
|
|
||||||
const info = data.info[sessionID]
|
|
||||||
const reduction = v2.reduce(data.session_message[sessionID] ?? [], event, info)
|
|
||||||
if (reduction) {
|
if (reduction) {
|
||||||
projectV2(reduction)
|
projectV2(reduction)
|
||||||
if (reduction.missing) hydrateV2Message(sessionID, reduction.missing)
|
if (reduction.missing) hydrateV2Message(sessionID, reduction.missing)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.agent.selected" && info) remember({ ...info, agent: event.data.agent })
|
const info = data.info[sessionID]
|
||||||
if (event.type === "session.model.selected") {
|
|
||||||
if (info) remember({ ...info, model: event.data.model })
|
|
||||||
if (data.session_message[sessionID]) hydrateV2Message(sessionID, event.id.replace(/^evt_/, "msg_"))
|
|
||||||
}
|
|
||||||
if (event.type === "session.renamed" && info)
|
if (event.type === "session.renamed" && info)
|
||||||
remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } })
|
remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } })
|
||||||
if (event.type === "session.renamed" && !info)
|
|
||||||
void resolve(sessionID)
|
|
||||||
.then((current) =>
|
|
||||||
remember({ ...current, title: event.data.title, time: { ...current.time, updated: event.created } }),
|
|
||||||
)
|
|
||||||
.catch(() => undefined)
|
|
||||||
if (event.type === "session.moved" && info)
|
if (event.type === "session.moved" && info)
|
||||||
remember({
|
remember({
|
||||||
...info,
|
...info,
|
||||||
projectID: event.data.projectID,
|
projectID: event.data.projectID ?? info.projectID,
|
||||||
location: event.data.location,
|
location: event.data.location,
|
||||||
subpath: event.data.subpath,
|
subpath: event.data.subpath,
|
||||||
time: { ...info.time, updated: event.created },
|
time: { ...info.time, updated: event.created },
|
||||||
@@ -1033,29 +934,12 @@ export function createServerSession(
|
|||||||
next: event.data.at,
|
next: event.data.at,
|
||||||
})
|
})
|
||||||
if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {})
|
if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {})
|
||||||
if (event.type === "session.revert.staged" && info) remember({ ...info, revert: event.data.revert })
|
|
||||||
if (event.type === "session.revert.cleared" && info) remember({ ...info, revert: undefined })
|
|
||||||
if (event.type === "session.revert.committed") {
|
|
||||||
messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1)
|
|
||||||
if (info) remember({ ...info, revert: undefined })
|
|
||||||
setData("input", sessionID, (items) => items?.filter((id) => id < event.data.to))
|
|
||||||
const source = data.session_message[sessionID] ?? []
|
|
||||||
const removed = source.filter((message) => message.id >= event.data.to).map((message) => message.id)
|
|
||||||
removedMessages.set(sessionID, new Set([...(removedMessages.get(sessionID) ?? []), ...removed]))
|
|
||||||
projectV2({
|
|
||||||
sessionID,
|
|
||||||
messages: source.filter((message) => message.id < event.data.to),
|
|
||||||
touched: [],
|
|
||||||
removed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
event.type === "session.revert.staged" ||
|
event.type === "session.revert.staged" ||
|
||||||
event.type === "session.revert.cleared" ||
|
event.type === "session.revert.cleared" ||
|
||||||
event.type === "session.revert.committed"
|
event.type === "session.revert.committed"
|
||||||
)
|
)
|
||||||
void resolve(sessionID, { force: true }).catch(() => {})
|
void resolve(sessionID, { force: true }).catch(() => {})
|
||||||
if (event.type === "session.revert.committed") void sync(sessionID, { force: true }).catch(() => {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const apply = (event: { type: string; properties?: unknown }) => {
|
const apply = (event: { type: string; properties?: unknown }) => {
|
||||||
@@ -1384,36 +1268,6 @@ export function createServerSession(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
sync,
|
sync,
|
||||||
async hydrateTransient(sessionID: string, load: () => Promise<{ pending: SessionInboxInfo[]; forms: FormInfo[] }>) {
|
|
||||||
while (true) {
|
|
||||||
const pendingAt = pendingRevision.get(sessionID) ?? 0
|
|
||||||
const formAt = formRevision.get(sessionID) ?? 0
|
|
||||||
const result = await load()
|
|
||||||
const pendingStable = (pendingRevision.get(sessionID) ?? 0) === pendingAt
|
|
||||||
const formStable = (formRevision.get(sessionID) ?? 0) === formAt
|
|
||||||
if (pendingStable) {
|
|
||||||
setData("pending", sessionID, reconcile(result.pending))
|
|
||||||
setData(
|
|
||||||
"input",
|
|
||||||
sessionID,
|
|
||||||
reconcile(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (formStable) setData("form", sessionID, reconcile(result.forms))
|
|
||||||
if (pendingStable && formStable) return
|
|
||||||
}
|
|
||||||
},
|
|
||||||
refreshPinned(hydrateTransient: (sessionID: string) => Promise<void>) {
|
|
||||||
return Promise.all(
|
|
||||||
[...pinned.keys()].flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
|
|
||||||
).then(() => undefined)
|
|
||||||
},
|
|
||||||
invalidate() {
|
|
||||||
invalidationRevision += 1
|
|
||||||
Object.keys(data.info).forEach((sessionID) => invalidated.add(sessionID))
|
|
||||||
Object.keys(data.message).forEach((sessionID) => invalidated.add(sessionID))
|
|
||||||
setMeta("at", {})
|
|
||||||
},
|
|
||||||
prefetch,
|
prefetch,
|
||||||
shouldPrefetch(sessionID: string, limit: number) {
|
shouldPrefetch(sessionID: string, limit: number) {
|
||||||
if (data.message[sessionID] === undefined) return true
|
if (data.message[sessionID] === undefined) return true
|
||||||
|
|||||||
@@ -9,13 +9,7 @@ import type {
|
|||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||||
import {
|
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
|
||||||
loadActiveSessionsQuery,
|
|
||||||
loadMcpQuery,
|
|
||||||
loadMcpResourcesQuery,
|
|
||||||
reconcileActiveSessionStatuses,
|
|
||||||
seedActiveSessionStatuses,
|
|
||||||
} from "./server-sync"
|
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
import { createServerSession } from "./server-session"
|
import { createServerSession } from "./server-session"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
@@ -105,17 +99,6 @@ describe("active session query", () => {
|
|||||||
next: 10,
|
next: 10,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("replaces stale active statuses after reconnect", () => {
|
|
||||||
const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"])
|
|
||||||
session.set("session_status", "ses_stale", { type: "busy" })
|
|
||||||
session.set("session_status", "ses_active", { type: "idle" })
|
|
||||||
|
|
||||||
reconcileActiveSessionStatuses(session, { ses_active: { type: "running" } })
|
|
||||||
|
|
||||||
expect(session.data.session_status.ses_stale).toEqual({ type: "idle" })
|
|
||||||
expect(session.data.session_status.ses_active).toEqual({ type: "busy" })
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("pickDirectoriesToEvict", () => {
|
describe("pickDirectoriesToEvict", () => {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { batch, createMemo, getOwner, onCleanup, untrack } from "solid-js"
|
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import type { InitError } from "../pages/error"
|
import type { InitError } from "../pages/error"
|
||||||
import { type ServerEvent, type ServerSDK } from "./server-sdk"
|
import { ServerSDK } from "./server-sdk"
|
||||||
import {
|
import {
|
||||||
bootstrapDirectory,
|
bootstrapDirectory,
|
||||||
bootstrapGlobal,
|
bootstrapGlobal,
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
loadAgentsQuery,
|
loadAgentsQuery,
|
||||||
loadCommands,
|
loadCommands,
|
||||||
loadGlobalConfigQuery,
|
loadGlobalConfigQuery,
|
||||||
loadIntegrationsQuery,
|
|
||||||
loadPathQuery,
|
loadPathQuery,
|
||||||
loadProjectsQuery,
|
loadProjectsQuery,
|
||||||
loadProvidersQuery,
|
loadProvidersQuery,
|
||||||
@@ -30,7 +29,7 @@ import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from
|
|||||||
import type { SolidQueryOptions } from "@tanstack/solid-query"
|
import type { SolidQueryOptions } from "@tanstack/solid-query"
|
||||||
import { createRefreshQueue } from "./global-sync/queue"
|
import { createRefreshQueue } from "./global-sync/queue"
|
||||||
import { directoryKey } from "./global-sync/utils"
|
import { directoryKey } from "./global-sync/utils"
|
||||||
import { pathKey, PathKey } from "@/utils/path-key"
|
import { PathKey } from "@/utils/path-key"
|
||||||
import { createDirSyncContext } from "./directory-sync"
|
import { createDirSyncContext } from "./directory-sync"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
@@ -54,8 +53,6 @@ import type {
|
|||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import { toggleMcp } from "./global-sync/mcp"
|
import { toggleMcp } from "./global-sync/mcp"
|
||||||
import { createServerSession, type ServerSession } from "./server-session"
|
import { createServerSession, type ServerSession } from "./server-session"
|
||||||
import { createCatalogSync } from "./server-sync/catalog"
|
|
||||||
import { createConnectionSync } from "./server-sync/connection"
|
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
@@ -69,16 +66,6 @@ type GlobalStore = {
|
|||||||
reload: undefined | "pending" | "complete"
|
reload: undefined | "pending" | "complete"
|
||||||
}
|
}
|
||||||
|
|
||||||
const SESSION_LIST_EVENTS = new Set([
|
|
||||||
"session.created",
|
|
||||||
"session.updated",
|
|
||||||
"session.deleted",
|
|
||||||
"session.moved",
|
|
||||||
"session.forked",
|
|
||||||
"session.renamed",
|
|
||||||
"session.usage.updated",
|
|
||||||
])
|
|
||||||
|
|
||||||
type McpListApi = {
|
type McpListApi = {
|
||||||
readonly list: (input?: McpListInput) => Promise<McpListOutput>
|
readonly list: (input?: McpListInput) => Promise<McpListOutput>
|
||||||
}
|
}
|
||||||
@@ -172,22 +159,11 @@ export function seedActiveSessionStatuses(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reconcileActiveSessionStatuses(
|
|
||||||
session: Pick<ServerSession, "data" | "set">,
|
|
||||||
active: SessionActiveOutput,
|
|
||||||
) {
|
|
||||||
Object.keys(session.data.session_status)
|
|
||||||
.filter((sessionID) => active[sessionID] === undefined && session.data.session_status[sessionID]?.type !== "idle")
|
|
||||||
.forEach((sessionID) => session.set("session_status", sessionID, { type: "idle" }))
|
|
||||||
Object.keys(active).forEach((sessionID) => session.set("session_status", sessionID, { type: "busy" }))
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
||||||
return {
|
return {
|
||||||
globalConfig: () => loadGlobalConfigQuery(scope),
|
globalConfig: () => loadGlobalConfigQuery(scope),
|
||||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
||||||
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
||||||
integrations: (directory: PathKey | null) => loadIntegrationsQuery(scope, directory, serverAPI.integration),
|
|
||||||
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
|
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
|
||||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
|
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
|
||||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
|
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
|
||||||
@@ -208,46 +184,29 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
const booting = new Map<string, Promise<void>>()
|
const booting = new Map<string, Promise<void>>()
|
||||||
const sessionLoads = new Map<string, Promise<void>>()
|
const sessionLoads = new Map<string, Promise<void>>()
|
||||||
const sessionMeta = new Map<string, { limit: number }>()
|
const sessionMeta = new Map<string, { limit: number }>()
|
||||||
const sessionRevision = new Map<string, number>()
|
|
||||||
|
|
||||||
const session = createServerSession(serverSDK.api.session, serverSDK.api.message)
|
const session = createServerSession(serverSDK.api.session, serverSDK.api.message)
|
||||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
|
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
|
||||||
const connected = () => serverSDK.connection.status() === "connected"
|
|
||||||
const hydrateSessionState = async (sessionID: string) => {
|
|
||||||
await session.hydrateTransient(sessionID, async () => {
|
|
||||||
const [pending, forms] = await Promise.all([
|
|
||||||
serverSDK.api.session.inbox.list({ sessionID }),
|
|
||||||
serverSDK.api.form.list({ sessionID }),
|
|
||||||
])
|
|
||||||
return { pending, forms }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const hydrateSession = (sessionID: string) => Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
|
|
||||||
|
|
||||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||||
queries: [
|
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
||||||
{ ...queryOptionsApi.globalConfig(), enabled: connected() },
|
|
||||||
{ ...queryOptionsApi.providers(null), enabled: connected() },
|
|
||||||
{ ...queryOptionsApi.path(null), enabled: connected() },
|
|
||||||
],
|
|
||||||
}))
|
}))
|
||||||
const activeSessionsQuery = useQuery(() => ({
|
const activeSessionsQuery = useQuery(() =>
|
||||||
...loadActiveSessionsQuery(serverSDK.scope, {
|
loadActiveSessionsQuery(serverSDK.scope, {
|
||||||
active: async () => {
|
active: async () => {
|
||||||
const active = await serverSDK.api.session.active()
|
const active = await serverSDK.api.session.active()
|
||||||
reconcileActiveSessionStatuses(session, active)
|
seedActiveSessionStatuses(session, active)
|
||||||
Object.keys(active).forEach((sessionID) => {
|
for (const sessionID of Object.keys(active)) {
|
||||||
void Promise.all([session.resolve(sessionID), hydrateSessionState(sessionID)]).catch(() => undefined)
|
void session.resolve(sessionID).catch(() => undefined)
|
||||||
})
|
}
|
||||||
return active
|
return active
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
enabled: connected(),
|
)
|
||||||
}))
|
|
||||||
|
|
||||||
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
||||||
get ready() {
|
get ready() {
|
||||||
return bootstrap.isSuccess
|
return !bootstrap.isPending
|
||||||
},
|
},
|
||||||
project: [],
|
project: [],
|
||||||
provider_auth: {},
|
provider_auth: {},
|
||||||
@@ -272,6 +231,21 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
|
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
|
||||||
|
const refreshProviders = () =>
|
||||||
|
queryClient.refetchQueries({
|
||||||
|
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
|
||||||
|
})
|
||||||
|
|
||||||
|
let bootedAt = 0
|
||||||
|
let bootingRoot = false
|
||||||
|
let eventFrame: number | undefined
|
||||||
|
let eventTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
|
||||||
|
if (eventTimer !== undefined) clearTimeout(eventTimer)
|
||||||
|
})
|
||||||
|
|
||||||
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
|
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
|
||||||
setGlobalStore("project", next)
|
setGlobalStore("project", next)
|
||||||
}
|
}
|
||||||
@@ -296,9 +270,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
setGlobalStore: setBootStore,
|
setGlobalStore: setBootStore,
|
||||||
queryClient,
|
queryClient,
|
||||||
})
|
})
|
||||||
return Date.now()
|
bootedAt = Date.now()
|
||||||
|
return bootedAt
|
||||||
},
|
},
|
||||||
enabled: connected(),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const set = ((...input: unknown[]) => {
|
const set = ((...input: unknown[]) => {
|
||||||
@@ -320,13 +294,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
|
|
||||||
const children = createChildStoreManager({
|
const children = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
connected,
|
|
||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
persist: persisted,
|
persist: persisted,
|
||||||
isBooting: (directory) => booting.has(directory),
|
isBooting: (directory) => booting.has(directory),
|
||||||
isLoadingSessions: (directory) => sessionLoads.has(directory),
|
isLoadingSessions: (directory) => sessionLoads.has(directory),
|
||||||
onBootstrap: (directory) => {
|
onBootstrap: (directory) => {
|
||||||
if (!connected()) return
|
|
||||||
void bootstrapInstance(directory)
|
void bootstrapInstance(directory)
|
||||||
},
|
},
|
||||||
onMcp: (directory, setStore) => {
|
onMcp: (directory, setStore) => {
|
||||||
@@ -349,60 +321,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
translate: language.t,
|
translate: language.t,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: {
|
global: {
|
||||||
get provider() {
|
provider: globalStore.provider,
|
||||||
return globalStore.provider
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const catalog = createCatalogSync({
|
|
||||||
scope: serverSDK.scope,
|
|
||||||
queryClient,
|
|
||||||
active: () => Object.keys(children.children).filter(children.active).map(pathKey),
|
|
||||||
load: (directory) =>
|
|
||||||
Promise.all([
|
|
||||||
queryClient.fetchQuery(queryOptionsApi.providers(directory)),
|
|
||||||
queryClient.fetchQuery(queryOptionsApi.integrations(directory)),
|
|
||||||
]).then(() => undefined),
|
|
||||||
})
|
|
||||||
const refreshVcs = (directory: string) =>
|
|
||||||
serverSDK.api.vcs
|
|
||||||
.get({ location: { directory } })
|
|
||||||
.then((result) =>
|
|
||||||
children.vcs(directory, {
|
|
||||||
branch: result.data.branch.current,
|
|
||||||
default_branch: result.data.branch.default,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.catch(() => undefined)
|
|
||||||
const connection = createConnectionSync({
|
|
||||||
status: serverSDK.connection.status,
|
|
||||||
invalidate: () => {
|
|
||||||
session.invalidate()
|
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
predicate: (query) => query.queryKey[0] === serverSDK.scope,
|
|
||||||
refetchType: "none",
|
|
||||||
})
|
|
||||||
},
|
|
||||||
connected: (info) => {
|
|
||||||
if (info.reconnect) void session.refreshPinned(hydrateSessionState).catch(() => undefined)
|
|
||||||
if (activeSessionsQuery.data !== undefined && !activeSessionsQuery.isFetching) void activeSessionsQuery.refetch()
|
|
||||||
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
|
|
||||||
Object.keys(children.children)
|
|
||||||
.filter(children.active)
|
|
||||||
.forEach((directory) => {
|
|
||||||
queue.push(directory)
|
|
||||||
if (children.children[directory]?.[0].status !== "loading") void refreshVcs(directory)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
async function loadCurrentSessions(directory: string, key: PathKey, limit: number) {
|
|
||||||
while (true) {
|
|
||||||
const revision = sessionRevision.get(key) ?? 0
|
|
||||||
const result = await loadRootSessions({ api: serverSDK.api.session, directory, limit })
|
|
||||||
if ((sessionRevision.get(key) ?? 0) === revision) return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSessions(directory: string, options?: { limit?: number }) {
|
async function loadSessions(directory: string, options?: { limit?: number }) {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
@@ -433,7 +354,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
.fetchQuery({
|
.fetchQuery({
|
||||||
...queryOptionsApi.sessions(key),
|
...queryOptionsApi.sessions(key),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
loadCurrentSessions(directory, key, limit)
|
loadRootSessions({ api: serverSDK.api.session, directory, limit })
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
const nonArchived = (x.data ?? [])
|
const nonArchived = (x.data ?? [])
|
||||||
.filter((s) => !!s?.id)
|
.filter((s) => !!s?.id)
|
||||||
@@ -489,28 +410,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
children.pin(key)
|
children.pin(key)
|
||||||
const promise = Promise.resolve().then(async () => {
|
const promise = Promise.resolve().then(async () => {
|
||||||
const child = children.ensureChild(directory)
|
const child = children.ensureChild(directory)
|
||||||
const initial = child[0].status === "loading"
|
const cache = children.vcsCache.get(key)
|
||||||
await Promise.all([
|
if (!cache) return
|
||||||
bootstrapDirectory({
|
await bootstrapDirectory({
|
||||||
directory,
|
directory,
|
||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
mcp: children.mcp(key),
|
mcp: children.mcp(key),
|
||||||
global: {
|
global: {
|
||||||
config: globalStore.config,
|
config: globalStore.config,
|
||||||
path: globalStore.path,
|
path: globalStore.path,
|
||||||
project: globalStore.project,
|
project: globalStore.project,
|
||||||
provider: globalStore.provider,
|
provider: globalStore.provider,
|
||||||
},
|
},
|
||||||
api: serverSDK.api,
|
api: serverSDK.api,
|
||||||
store: child[0],
|
store: child[0],
|
||||||
setStore: child[1],
|
setStore: child[1],
|
||||||
loadSessions,
|
vcsCache: cache,
|
||||||
translate: language.t,
|
loadSessions,
|
||||||
queryClient,
|
translate: language.t,
|
||||||
session,
|
queryClient,
|
||||||
}),
|
session,
|
||||||
initial ? refreshVcs(directory) : Promise.resolve(),
|
})
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
booting.set(key, promise)
|
booting.set(key, promise)
|
||||||
@@ -537,43 +457,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
loadLsp() {},
|
loadLsp() {},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const updateHomeSession = (info: Parameters<typeof session.remember>[0]) =>
|
|
||||||
homeSessions.apply({
|
|
||||||
type: "session.updated",
|
|
||||||
properties: { sessionID: info.id, info },
|
|
||||||
})
|
|
||||||
const markSessionListChanged = (event: ServerEvent, directory: string, previousDirectory?: string) => {
|
|
||||||
if (SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) {
|
|
||||||
const key = directoryKey(directory)
|
|
||||||
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
if (!previousDirectory || previousDirectory === directory) return
|
|
||||||
const key = directoryKey(previousDirectory)
|
|
||||||
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
const toDirectoryEvent = (event: ServerEvent) => {
|
|
||||||
if (event.current?.type === "session.created") return
|
|
||||||
if (
|
|
||||||
event.current?.type !== "session.renamed" &&
|
|
||||||
event.current?.type !== "session.moved" &&
|
|
||||||
event.current?.type !== "session.usage.updated"
|
|
||||||
)
|
|
||||||
return event
|
|
||||||
const info = session.get(event.current.data.sessionID)
|
|
||||||
if (info) return { type: "session.updated", properties: { info } }
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsub = serverSDK.event.listen((e) => {
|
const unsub = serverSDK.event.listen((e) => {
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const event = e.details
|
const event = e.details
|
||||||
const eventType: string = event.type
|
const eventType: string = event.type
|
||||||
const previousDirectory =
|
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||||
event.current?.type === "session.moved"
|
|
||||||
? session.get(event.current.data.sessionID)?.location.directory
|
|
||||||
: undefined
|
|
||||||
markSessionListChanged(event, directory, previousDirectory)
|
|
||||||
if (event.current) session.applyV2(event.current)
|
if (event.current) session.applyV2(event.current)
|
||||||
session.apply(event)
|
session.apply(event)
|
||||||
if (event.current?.type === "session.created")
|
if (event.current?.type === "session.created")
|
||||||
@@ -596,41 +487,35 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
if (event.type === "session.created" || event.type === "session.deleted") {
|
if (event.type === "session.created" || event.type === "session.deleted") {
|
||||||
if ("info" in event.properties) homeSessions.apply(event as Parameters<typeof homeSessions.apply>[0])
|
if ("info" in event.properties) homeSessions.apply(event as Parameters<typeof homeSessions.apply>[0])
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
event.current?.type === "session.renamed" ||
|
|
||||||
event.current?.type === "session.moved" ||
|
|
||||||
event.current?.type === "session.usage.updated"
|
|
||||||
) {
|
|
||||||
const sessionID = event.current.data.sessionID
|
|
||||||
const info = session.get(sessionID)
|
|
||||||
if (info) updateHomeSession(info)
|
|
||||||
if (!info)
|
|
||||||
void session
|
|
||||||
.resolve(sessionID)
|
|
||||||
.then(() => {
|
|
||||||
const current = session.get(sessionID)
|
|
||||||
if (current) updateHomeSession(current)
|
|
||||||
})
|
|
||||||
.catch(() => undefined)
|
|
||||||
}
|
|
||||||
homeSessions.refresh(event.type)
|
homeSessions.refresh(event.type)
|
||||||
catalog.handleEvent({ type: eventType, directory })
|
if (eventType === "integration.connection.updated") void refreshProviders()
|
||||||
connection.handleEvent({ type: eventType, directory })
|
|
||||||
|
|
||||||
if (directory === "global") {
|
if (directory === "global") {
|
||||||
|
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
|
||||||
|
void activeSessionsQuery.refetch()
|
||||||
applyGlobalEvent({
|
applyGlobalEvent({
|
||||||
event,
|
event,
|
||||||
project: globalStore.project,
|
project: globalStore.project,
|
||||||
refresh: () => void bootstrap.refetch(),
|
refresh: () => {
|
||||||
|
if (recent) return
|
||||||
|
bootstrap.refetch()
|
||||||
|
},
|
||||||
setGlobalProject: setProjects,
|
setGlobalProject: setProjects,
|
||||||
})
|
})
|
||||||
if (
|
if (
|
||||||
eventType === "config.updated" ||
|
eventType === "config.updated" ||
|
||||||
|
eventType === "catalog.updated" ||
|
||||||
eventType === "agent.updated" ||
|
eventType === "agent.updated" ||
|
||||||
eventType === "project.directories.updated"
|
eventType === "project.directories.updated"
|
||||||
)
|
)
|
||||||
bootstrap.refetch()
|
bootstrap.refetch()
|
||||||
if (eventType === "global.disposed") Object.keys(children.children).filter(children.active).forEach(queue.push)
|
if (eventType === "server.connected" || eventType === "global.disposed") {
|
||||||
|
if (recent) return
|
||||||
|
for (const directory of Object.keys(children.children)) {
|
||||||
|
if (!children.active(directory)) continue
|
||||||
|
queue.push(directory)
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,6 +536,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
event.current?.type === "session.moved" ||
|
event.current?.type === "session.moved" ||
|
||||||
// event.current?.type === "session.archived" ||
|
// event.current?.type === "session.archived" ||
|
||||||
event.current?.type === "session.forked" ||
|
event.current?.type === "session.forked" ||
|
||||||
|
eventType === "command.updated" ||
|
||||||
eventType === "config.updated" ||
|
eventType === "config.updated" ||
|
||||||
eventType === "agent.updated"
|
eventType === "agent.updated"
|
||||||
)
|
)
|
||||||
@@ -658,39 +544,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key))
|
if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key))
|
||||||
if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key))
|
if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key))
|
||||||
const [store, setStore] = existing
|
const [store, setStore] = existing
|
||||||
if (eventType === "agent.updated")
|
applyDirectoryEvent({
|
||||||
void queryClient
|
event,
|
||||||
.fetchQuery(queryOptionsApi.agents(key))
|
directory,
|
||||||
.then((data) => setStore("agent", data))
|
store,
|
||||||
.catch(() => {})
|
setStore,
|
||||||
if (eventType === "command.updated")
|
push: (directory) => {
|
||||||
void loadCommands(directory, serverSDK.api.command)
|
if (children.active(directory)) queue.push(directory)
|
||||||
.then((commands) => setStore("command", commands))
|
},
|
||||||
.catch(() => {})
|
retainedLimit: sessionMeta.get(key)?.limit,
|
||||||
if (eventType === "project.directories.updated") void bootstrap.refetch()
|
sessionContent: false,
|
||||||
const projected = toDirectoryEvent(event)
|
permission: session.data.permission,
|
||||||
if (projected)
|
vcsCache: children.vcsCache.get(key),
|
||||||
applyDirectoryEvent({
|
loadLsp: () => {
|
||||||
event: projected,
|
if (!children.active(key)) return
|
||||||
directory,
|
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
||||||
store,
|
},
|
||||||
setStore,
|
loadReferences: () => {
|
||||||
push: (directory) => {
|
if (!children.active(key)) return
|
||||||
if (children.active(directory)) queue.push(directory)
|
void queryClient.fetchQuery(queryOptionsApi.references(key))
|
||||||
},
|
},
|
||||||
retainedLimit: sessionMeta.get(key)?.limit,
|
})
|
||||||
sessionContent: false,
|
|
||||||
permission: session.data.permission,
|
|
||||||
vcsCache: children.vcsCache.get(key),
|
|
||||||
loadLsp: () => {
|
|
||||||
if (!children.active(key)) return
|
|
||||||
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
|
||||||
},
|
|
||||||
loadReferences: () => {
|
|
||||||
if (!children.active(key)) return
|
|
||||||
void queryClient.fetchQuery(queryOptionsApi.references(key))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
@@ -703,6 +577,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (typeof requestAnimationFrame === "function") {
|
||||||
|
eventFrame = requestAnimationFrame(() => {
|
||||||
|
eventFrame = undefined
|
||||||
|
eventTimer = setTimeout(() => {
|
||||||
|
eventTimer = undefined
|
||||||
|
void serverSDK.event.start()
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
eventTimer = setTimeout(() => {
|
||||||
|
eventTimer = undefined
|
||||||
|
void serverSDK.event.start()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const projectApi = {
|
const projectApi = {
|
||||||
loadSessions,
|
loadSessions,
|
||||||
meta(directory: string, patch: ProjectMeta) {
|
meta(directory: string, patch: ProjectMeta) {
|
||||||
@@ -743,11 +634,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
peek: children.peek,
|
peek: children.peek,
|
||||||
disableMcp: children.disableMcp,
|
disableMcp: children.disableMcp,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
refreshProviders: catalog.refreshActive,
|
refreshProviders,
|
||||||
// bootstrap,
|
// bootstrap,
|
||||||
updateConfig: updateConfigMutation.mutateAsync,
|
updateConfig: updateConfigMutation.mutateAsync,
|
||||||
project: projectApi,
|
project: projectApi,
|
||||||
session: Object.assign(session, { hydrate: hydrateSession }),
|
session,
|
||||||
homeSessions,
|
homeSessions,
|
||||||
mcp: {
|
mcp: {
|
||||||
toggle: async (directory: string, name: string) => {
|
toggle: async (directory: string, name: string) => {
|
||||||
@@ -808,13 +699,13 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
|
|||||||
name: "ServerSync",
|
name: "ServerSync",
|
||||||
// Returns an accessor so the resolved server can change reactively without
|
// Returns an accessor so the resolved server can change reactively without
|
||||||
// re-instantiating the subtree (mirrors useServerSDK).
|
// re-instantiating the subtree (mirrors useServerSDK).
|
||||||
init: (props: { server?: ServerConnection.Any }) => {
|
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
|
|
||||||
return createMemo<ServerSync>(() => {
|
return createMemo<ServerSync>(() => {
|
||||||
const conn = props.server ?? server.current
|
const conn = props.server?.() ?? server.current
|
||||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||||
return global.ensureServerCtx(conn).sync
|
return global.ensureServerCtx(conn).sync
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { expect, test } from "bun:test"
|
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
|
||||||
import { createCatalogSync } from "./catalog"
|
|
||||||
import { pathKey } from "@/utils/path-key"
|
|
||||||
|
|
||||||
test("invalidates the catalog for the event location", async () => {
|
|
||||||
const queryClient = new QueryClient()
|
|
||||||
const one = [ServerScope.local, "/one", "providers"] as const
|
|
||||||
const integrations = [ServerScope.local, "/one", "integrations"] as const
|
|
||||||
const two = [ServerScope.local, "/two", "providers"] as const
|
|
||||||
queryClient.setQueryData(one, { providers: ["one"] })
|
|
||||||
queryClient.setQueryData(integrations, { integrations: ["one"] })
|
|
||||||
queryClient.setQueryData(two, { providers: ["two"] })
|
|
||||||
const catalog = createCatalogSync({
|
|
||||||
scope: ServerScope.local,
|
|
||||||
queryClient,
|
|
||||||
active: () => [pathKey("/one"), pathKey("/two")],
|
|
||||||
load: async () => {},
|
|
||||||
})
|
|
||||||
|
|
||||||
catalog.handleEvent({ type: "catalog.updated", directory: "/one" })
|
|
||||||
await Bun.sleep(0)
|
|
||||||
|
|
||||||
expect(queryClient.getQueryState(one)?.isInvalidated).toBe(true)
|
|
||||||
expect(queryClient.getQueryState(integrations)?.isInvalidated).toBe(true)
|
|
||||||
expect(queryClient.getQueryState(two)?.isInvalidated).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("invalidates global and active catalogs after connection", async () => {
|
|
||||||
const queryClient = new QueryClient()
|
|
||||||
const global = [ServerScope.local, null, "providers"] as const
|
|
||||||
const active = [ServerScope.local, "/active", "providers"] as const
|
|
||||||
const passive = [ServerScope.local, "/passive", "providers"] as const
|
|
||||||
queryClient.setQueryData(global, {})
|
|
||||||
queryClient.setQueryData(active, {})
|
|
||||||
queryClient.setQueryData(passive, {})
|
|
||||||
const catalog = createCatalogSync({
|
|
||||||
scope: ServerScope.local,
|
|
||||||
queryClient,
|
|
||||||
active: () => [pathKey("/active")],
|
|
||||||
load: async () => {},
|
|
||||||
})
|
|
||||||
|
|
||||||
catalog.handleEvent({ type: "server.connected", directory: "global" })
|
|
||||||
await Bun.sleep(0)
|
|
||||||
|
|
||||||
expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true)
|
|
||||||
expect(queryClient.getQueryState(active)?.isInvalidated).toBe(true)
|
|
||||||
expect(queryClient.getQueryState(passive)?.isInvalidated).toBe(false)
|
|
||||||
})
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import type { QueryClient } from "@tanstack/solid-query"
|
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
|
||||||
import { pathKey, type PathKey } from "@/utils/path-key"
|
|
||||||
|
|
||||||
type CatalogEvent = {
|
|
||||||
type: string
|
|
||||||
directory: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCatalogSync(input: {
|
|
||||||
scope: ServerScope
|
|
||||||
queryClient: QueryClient
|
|
||||||
active: () => PathKey[]
|
|
||||||
load: (directory: PathKey | null) => Promise<void>
|
|
||||||
}) {
|
|
||||||
function handleEvent(event: CatalogEvent) {
|
|
||||||
if (event.type === "server.connected") {
|
|
||||||
void refreshActive().catch(() => undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.type === "catalog.updated" ||
|
|
||||||
event.type === "integration.updated" ||
|
|
||||||
event.type === "integration.connection.updated"
|
|
||||||
) {
|
|
||||||
void refresh(event.directory === "global" ? null : pathKey(event.directory)).catch(() => undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh(directory: PathKey | null) {
|
|
||||||
await Promise.all(
|
|
||||||
["providers", "integrations"].map((resource) =>
|
|
||||||
input.queryClient.invalidateQueries({
|
|
||||||
queryKey: [input.scope, directory, resource],
|
|
||||||
exact: true,
|
|
||||||
refetchType: "none",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await input.load(directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshActive() {
|
|
||||||
return Promise.all([null, ...new Set(input.active())].map(refresh)).then(() => undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
handleEvent,
|
|
||||||
refresh,
|
|
||||||
refreshActive,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { expect, test } from "bun:test"
|
|
||||||
import { createRoot, createSignal } from "solid-js"
|
|
||||||
import { createConnectionSync } from "./connection"
|
|
||||||
|
|
||||||
test("invalidates disconnected data and synchronizes after the handshake", () => {
|
|
||||||
const calls: string[] = []
|
|
||||||
const dispose = createRoot((dispose) => {
|
|
||||||
const [status, setStatus] = createSignal<"connecting" | "connected" | "reconnecting">("connecting")
|
|
||||||
const connection = createConnectionSync({
|
|
||||||
status,
|
|
||||||
invalidate: () => calls.push("invalidate"),
|
|
||||||
connected: () => calls.push("connected"),
|
|
||||||
})
|
|
||||||
|
|
||||||
connection.handleEvent({ type: "server.connected", directory: "global" })
|
|
||||||
expect(calls).toContain("connected")
|
|
||||||
connection.handleEvent({ type: "server.connected", directory: "/repo" })
|
|
||||||
expect(calls.filter((call) => call === "connected")).toHaveLength(1)
|
|
||||||
setStatus("connected")
|
|
||||||
return dispose
|
|
||||||
})
|
|
||||||
dispose()
|
|
||||||
})
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { createEffect, type Accessor } from "solid-js"
|
|
||||||
import type { ServerConnectionStatus } from "../server-sdk"
|
|
||||||
|
|
||||||
export function createConnectionSync(input: {
|
|
||||||
status: Accessor<ServerConnectionStatus>
|
|
||||||
invalidate: () => void
|
|
||||||
connected: (info: { reconnect: boolean }) => void
|
|
||||||
}) {
|
|
||||||
createEffect(() => {
|
|
||||||
if (input.status() === "connected") return
|
|
||||||
input.invalidate()
|
|
||||||
})
|
|
||||||
|
|
||||||
let connectedOnce = false
|
|
||||||
function handleEvent(event: { type: string; directory: string }) {
|
|
||||||
if (event.directory !== "global" || event.type !== "server.connected") return
|
|
||||||
input.connected({ reconnect: connectedOnce })
|
|
||||||
connectedOnce = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return { handleEvent }
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { pathKey } from "@/utils/path-key"
|
|
||||||
import { createQuery } from "@tanstack/solid-query"
|
|
||||||
import type { Accessor } from "solid-js"
|
|
||||||
|
|
||||||
export function useIntegrations(directory: Accessor<string | undefined>) {
|
|
||||||
const serverSDK = useServerSDK()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const query = createQuery(() => {
|
|
||||||
const value = directory()
|
|
||||||
return {
|
|
||||||
...serverSync().queryOptions.integrations(value ? pathKey(value) : null),
|
|
||||||
enabled: serverSDK().connection.status() === "connected",
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
list: () => (query.isSuccess || query.isRefetchError ? query.data : []),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
import { Iterable, pipe } from "effect"
|
import { Iterable, pipe } from "effect"
|
||||||
import { createEffect, createMemo, type Accessor } from "solid-js"
|
import { createEffect, createMemo, type Accessor } from "solid-js"
|
||||||
import { selectProviderCatalog } from "./provider-catalog"
|
import { selectProviderCatalog } from "./provider-catalog"
|
||||||
@@ -17,7 +19,8 @@ const popularProviderSet = new Set(popularProviders)
|
|||||||
|
|
||||||
export function useProviders(directory: Accessor<string | undefined>) {
|
export function useProviders(directory: Accessor<string | undefined>) {
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const dir = directory
|
const params = useParams()
|
||||||
|
const dir = () => (directory ? directory() : decode64(params.dir))
|
||||||
const providers = () => {
|
const providers = () => {
|
||||||
const value = dir()
|
const value = dir()
|
||||||
const projectStore = value ? serverSync().child(value)[0] : undefined
|
const projectStore = value ? serverSync().child(value)[0] : undefined
|
||||||
@@ -36,11 +39,6 @@ export function useProviders(directory: Accessor<string | undefined>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => {
|
|
||||||
const value = dir()
|
|
||||||
if (!value) return false
|
|
||||||
return serverSync().child(value)[0].provider_ready
|
|
||||||
},
|
|
||||||
all: () => providers().all,
|
all: () => providers().all,
|
||||||
default: () => providers().default,
|
default: () => providers().default,
|
||||||
popular: () =>
|
popular: () =>
|
||||||
|
|||||||
@@ -673,17 +673,6 @@ export const dict = {
|
|||||||
"session.error.notFound": "This session cannot be found",
|
"session.error.notFound": "This session cannot be found",
|
||||||
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
|
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
|
||||||
"session.error.notFound.closeTab": "Close Tab",
|
"session.error.notFound.closeTab": "Close Tab",
|
||||||
"session.background.moveTasks": "Move {{tasks}} to background",
|
|
||||||
"session.background.inBackground": "Running {{tasks}} in background",
|
|
||||||
"session.background.combine": "{{first}} and {{second}}",
|
|
||||||
"session.background.shell.one": "{{count}} shell",
|
|
||||||
"session.background.shell.other": "{{count}} shells",
|
|
||||||
"session.background.subagent.one": "{{count}} subagent",
|
|
||||||
"session.background.subagent.other": "{{count}} subagents",
|
|
||||||
"command.session.background": "Move to background",
|
|
||||||
"session.timeline.notice.finished": "{{actor}} finished",
|
|
||||||
"session.timeline.notice.failed": "{{actor}} failed",
|
|
||||||
"session.timeline.notice.cancelled": "{{actor}} cancelled",
|
|
||||||
"session.error.serverConnection": "Can't connect to this server",
|
"session.error.serverConnection": "Can't connect to this server",
|
||||||
"session.review.filesChanged": "Files Changed {{count}}",
|
"session.review.filesChanged": "Files Changed {{count}}",
|
||||||
"session.review.change.one": "Change",
|
"session.review.change.one": "Change",
|
||||||
@@ -903,46 +892,14 @@ export const dict = {
|
|||||||
"settings.section.desktop": "Desktop",
|
"settings.section.desktop": "Desktop",
|
||||||
"settings.section.server": "Server",
|
"settings.section.server": "Server",
|
||||||
"settings.tab.general": "General",
|
"settings.tab.general": "General",
|
||||||
"settings.tab.preferences": "Preferences",
|
|
||||||
"settings.tab.shortcuts": "Shortcuts",
|
"settings.tab.shortcuts": "Shortcuts",
|
||||||
"settings.tab.notifications": "Notifications",
|
|
||||||
"settings.tab.projects": "Projects",
|
|
||||||
"settings.tab.extensions": "Extensions",
|
|
||||||
"settings.preferences.description": "Customize preferences and theme and default behavior",
|
|
||||||
"settings.appearance.description": "Customize theme and fonts",
|
|
||||||
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
|
|
||||||
"settings.shortcuts.description": "Customize shortcuts for common actions",
|
|
||||||
"settings.servers.description": "Manage server connections",
|
|
||||||
"settings.projects.title": "Projects",
|
|
||||||
"settings.projects.description": "Manage project settings on this server",
|
|
||||||
"settings.projects.empty": "No projects found",
|
|
||||||
"settings.projects.server.all": "All servers",
|
|
||||||
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
|
||||||
"settings.extensions.description": "Manage extensions available on this server",
|
|
||||||
"settings.extensions.tab.mcps": "MCPs",
|
|
||||||
"settings.extensions.tab.skills": "Skills",
|
|
||||||
"settings.extensions.availableAll": "Available to all projects",
|
|
||||||
"settings.extensions.manageConfig": "Manage in opencode.json",
|
|
||||||
"settings.extensions.addSkills": "How to add skills",
|
|
||||||
"settings.desktop.section.wsl": "WSL",
|
"settings.desktop.section.wsl": "WSL",
|
||||||
"settings.desktop.wsl.title": "WSL integration",
|
"settings.desktop.wsl.title": "WSL integration",
|
||||||
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
||||||
"dialog.server.authenticate.title": "Authenticate",
|
|
||||||
"project.settings.general.description": "Manage project name and appearance",
|
|
||||||
"project.settings.scripts": "Scripts",
|
|
||||||
"project.settings.scripts.description": "Configure scripts for this project",
|
|
||||||
"project.settings.extensions.description": "View extensions available to this project",
|
|
||||||
"project.settings.extensions.tab.lsps": "LSPs",
|
|
||||||
"project.settings.extensions.added": "Added to this project",
|
|
||||||
"project.settings.extensions.shared": "Shared with all projects",
|
|
||||||
"project.settings.extensions.lsp.detected": "Detected language servers",
|
|
||||||
"project.settings.extensions.lsp.description": "Auto-detected from file types",
|
|
||||||
"project.settings.extensions.setupRequired": "Setup required",
|
|
||||||
|
|
||||||
"settings.general.section.appearance": "Appearance",
|
"settings.general.section.appearance": "Appearance",
|
||||||
"settings.general.section.general": "General",
|
|
||||||
"settings.general.section.advanced": "Advanced",
|
"settings.general.section.advanced": "Advanced",
|
||||||
"settings.general.section.notifications": "Desktop notifications",
|
"settings.general.section.notifications": "System notifications",
|
||||||
"settings.general.section.updates": "Updates",
|
"settings.general.section.updates": "Updates",
|
||||||
"settings.general.section.sounds": "Sound effects",
|
"settings.general.section.sounds": "Sound effects",
|
||||||
"settings.general.section.feed": "Feed",
|
"settings.general.section.feed": "Feed",
|
||||||
@@ -1103,7 +1060,7 @@ export const dict = {
|
|||||||
"settings.shortcuts.group.prompt": "Prompt",
|
"settings.shortcuts.group.prompt": "Prompt",
|
||||||
|
|
||||||
"settings.providers.title": "Providers",
|
"settings.providers.title": "Providers",
|
||||||
"settings.providers.description": "Connect and manage model providers",
|
"settings.providers.description": "Provider settings will be configurable here.",
|
||||||
"settings.providers.section.connected": "Connected providers",
|
"settings.providers.section.connected": "Connected providers",
|
||||||
"settings.providers.connected.empty": "No connected providers",
|
"settings.providers.connected.empty": "No connected providers",
|
||||||
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
|
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
|
||||||
@@ -1114,7 +1071,7 @@ export const dict = {
|
|||||||
"settings.providers.tag.custom": "Custom",
|
"settings.providers.tag.custom": "Custom",
|
||||||
"settings.providers.tag.other": "Other",
|
"settings.providers.tag.other": "Other",
|
||||||
"settings.models.title": "Models",
|
"settings.models.title": "Models",
|
||||||
"settings.models.description": "Choose which models appear in model picker",
|
"settings.models.description": "Model settings will be configurable here.",
|
||||||
"settings.agents.title": "Agents",
|
"settings.agents.title": "Agents",
|
||||||
"settings.agents.description": "Agent settings will be configurable here.",
|
"settings.agents.description": "Agent settings will be configurable here.",
|
||||||
"settings.commands.title": "Commands",
|
"settings.commands.title": "Commands",
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { desktopNativePluralCategories } from "./desktop-native"
|
||||||
|
|
||||||
|
const appLocales = [
|
||||||
|
"ar",
|
||||||
|
"br",
|
||||||
|
"bs",
|
||||||
|
"da",
|
||||||
|
"de",
|
||||||
|
"es",
|
||||||
|
"fr",
|
||||||
|
"ja",
|
||||||
|
"ko",
|
||||||
|
"no",
|
||||||
|
"pl",
|
||||||
|
"ru",
|
||||||
|
"uk",
|
||||||
|
"th",
|
||||||
|
"tr",
|
||||||
|
"zh",
|
||||||
|
"zht",
|
||||||
|
"hi",
|
||||||
|
"nl",
|
||||||
|
"id",
|
||||||
|
"vi",
|
||||||
|
"it",
|
||||||
|
"ur",
|
||||||
|
"pa",
|
||||||
|
"az",
|
||||||
|
"fi",
|
||||||
|
"sv",
|
||||||
|
"am",
|
||||||
|
"bg",
|
||||||
|
"bn",
|
||||||
|
"ca",
|
||||||
|
"cs",
|
||||||
|
"dv",
|
||||||
|
"dz",
|
||||||
|
"el",
|
||||||
|
"et",
|
||||||
|
"fa",
|
||||||
|
"fo",
|
||||||
|
"hr",
|
||||||
|
"hu",
|
||||||
|
"hy",
|
||||||
|
"is",
|
||||||
|
"ka",
|
||||||
|
"km",
|
||||||
|
"lo",
|
||||||
|
"lt",
|
||||||
|
"lv",
|
||||||
|
"mk",
|
||||||
|
"mn",
|
||||||
|
"ms",
|
||||||
|
"my",
|
||||||
|
"ne",
|
||||||
|
"ro",
|
||||||
|
"si",
|
||||||
|
"sk",
|
||||||
|
"sl",
|
||||||
|
"sq",
|
||||||
|
"sr",
|
||||||
|
"tg",
|
||||||
|
"tk",
|
||||||
|
"uz",
|
||||||
|
] as const
|
||||||
|
const desktopLocales = appLocales
|
||||||
|
const pluralCategories = new Map(
|
||||||
|
appLocales.map(
|
||||||
|
(locale) =>
|
||||||
|
[
|
||||||
|
locale,
|
||||||
|
desktopNativePluralCategories(locale).filter((category) => category !== "one" && category !== "other"),
|
||||||
|
] as const,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const domains = [
|
||||||
|
{
|
||||||
|
name: "app",
|
||||||
|
source: "./en.ts",
|
||||||
|
target: (locale: string) => `./${locale}.ts`,
|
||||||
|
locales: appLocales,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ui",
|
||||||
|
source: "../../../ui/src/i18n/en.ts",
|
||||||
|
target: (locale: string) => `../../../ui/src/i18n/${locale}.ts`,
|
||||||
|
locales: appLocales,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "desktop",
|
||||||
|
source: "../../../desktop/src/renderer/i18n/en.ts",
|
||||||
|
target: (locale: string) => `../../../desktop/src/renderer/i18n/${locale}.ts`,
|
||||||
|
locales: desktopLocales,
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
describe("i18n parity", () => {
|
||||||
|
test("non-English locales have every English key and required plural variants", async () => {
|
||||||
|
for (const domain of domains) {
|
||||||
|
const source = await dictionary(domain.source)
|
||||||
|
for (const locale of domain.locales) {
|
||||||
|
const target = await dictionary(domain.target(locale))
|
||||||
|
const missing = Object.keys(source).filter((key) => !Object.hasOwn(target, key))
|
||||||
|
const extra = Object.keys(target)
|
||||||
|
.filter((key) => !Object.hasOwn(source, key))
|
||||||
|
.sort()
|
||||||
|
const expected = pluralFamilies(source)
|
||||||
|
.flatMap((key) => (pluralCategories.get(locale) ?? []).map((category) => `${key}.${category}`))
|
||||||
|
.sort()
|
||||||
|
expect({ domain: domain.name, locale, missing, extra }).toEqual({
|
||||||
|
domain: domain.name,
|
||||||
|
locale,
|
||||||
|
missing: [],
|
||||||
|
extra: expected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("non-English locales preserve English placeholders", async () => {
|
||||||
|
for (const domain of domains) {
|
||||||
|
const source = await dictionary(domain.source)
|
||||||
|
for (const locale of domain.locales) {
|
||||||
|
const target = await dictionary(domain.target(locale))
|
||||||
|
const mismatched = Object.keys(source).filter(
|
||||||
|
(key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(),
|
||||||
|
)
|
||||||
|
const pluralMismatched = pluralFamilies(source).flatMap((key) =>
|
||||||
|
(pluralCategories.get(locale) ?? [])
|
||||||
|
.map((category) => `${key}.${category}`)
|
||||||
|
.filter((variant) => placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join()),
|
||||||
|
)
|
||||||
|
expect({ domain: domain.name, locale, mismatched, pluralMismatched }).toEqual({
|
||||||
|
domain: domain.name,
|
||||||
|
locale,
|
||||||
|
mismatched: [],
|
||||||
|
pluralMismatched: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("non-English locales translate targeted unseen session keys", async () => {
|
||||||
|
const source = await dictionary("./en.ts")
|
||||||
|
for (const locale of appLocales) {
|
||||||
|
const target = await dictionary(`./${locale}.ts`)
|
||||||
|
for (const key of ["command.session.previous.unseen", "command.session.next.unseen"]) {
|
||||||
|
expect(target[key]).toBeDefined()
|
||||||
|
expect(target[key]).not.toBe(source[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("changed-file summary keys preserve rendered English copy and localize complete phrases", async () => {
|
||||||
|
const source = await dictionary("../../../ui/src/i18n/en.ts")
|
||||||
|
expect(source["ui.sessionTurn.diffs.changed.one"].replace("{{count}}", "1")).toBe("1 Changed file")
|
||||||
|
expect(source["ui.sessionTurn.diffs.changed.other"].replace("{{count}}", "2")).toBe("2 Changed files")
|
||||||
|
expect(source["ui.sessionTurn.diffs.changed"]).toBeUndefined()
|
||||||
|
|
||||||
|
for (const locale of appLocales) {
|
||||||
|
const target = await dictionary(`../../../ui/src/i18n/${locale}.ts`)
|
||||||
|
for (const key of ["ui.sessionTurn.diffs.changed.one", "ui.sessionTurn.diffs.changed.other"]) {
|
||||||
|
expect(target[key].trim()).not.toBe("")
|
||||||
|
expect(placeholders(target[key])).toEqual(["count"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("i18n plural parity", () => {
|
||||||
|
test("locale-specific categories exist and preserve count placeholders", async () => {
|
||||||
|
for (const domain of domains.slice(0, 2)) {
|
||||||
|
const source = await dictionary(domain.source)
|
||||||
|
const families = pluralFamilies(source)
|
||||||
|
for (const locale of domain.locales) {
|
||||||
|
const target = await dictionary(domain.target(locale))
|
||||||
|
const missing = families.flatMap((key) =>
|
||||||
|
(pluralCategories.get(locale) ?? [])
|
||||||
|
.map((category) => `${key}.${category}`)
|
||||||
|
.filter((variant) => !Object.hasOwn(target, variant)),
|
||||||
|
)
|
||||||
|
const mismatched = families.flatMap((key) =>
|
||||||
|
(pluralCategories.get(locale) ?? [])
|
||||||
|
.map((category) => `${key}.${category}`)
|
||||||
|
.filter(
|
||||||
|
(variant) =>
|
||||||
|
Object.hasOwn(target, variant) &&
|
||||||
|
placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect({ domain: domain.name, locale, missing, mismatched }).toEqual({
|
||||||
|
domain: domain.name,
|
||||||
|
locale,
|
||||||
|
missing: [],
|
||||||
|
mismatched: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function dictionary(file: string) {
|
||||||
|
const module: unknown = await import(file)
|
||||||
|
if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) {
|
||||||
|
throw new Error(`Invalid translation dictionary: ${file}`)
|
||||||
|
}
|
||||||
|
return module.dict
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDictionary(value: unknown): value is Record<string, string> {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false
|
||||||
|
return Object.values(value).every((item) => typeof item === "string")
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeholders(value: string) {
|
||||||
|
return Array.from(value.matchAll(/{{\s*([^}]+?)\s*}}/g), (match) => match[1]).sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluralFamilies(dictionary: Record<string, string>) {
|
||||||
|
return Object.keys(dictionary)
|
||||||
|
.filter(
|
||||||
|
(key) =>
|
||||||
|
key.endsWith(".one") &&
|
||||||
|
dictionary[key].includes("{{count}}") &&
|
||||||
|
dictionary[`${key.slice(0, -4)}.other`]?.includes("{{count}}"),
|
||||||
|
)
|
||||||
|
.map((key) => key.slice(0, -4))
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { DataProvider } from "@opencode-ai/session-ui/context"
|
|||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { createEffect, createMemo, createResource, onCleanup, type ParentProps, Show } from "solid-js"
|
import { type Accessor, createEffect, createMemo, createResource, onCleanup, type ParentProps, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { LocalProvider } from "@/context/local"
|
import { LocalProvider } from "@/context/local"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
import { SDKProvider } from "@/context/sdk"
|
||||||
@@ -15,9 +15,9 @@ import { useServerSync } from "@/context/server-sync"
|
|||||||
|
|
||||||
export function DirectoryDataProvider(
|
export function DirectoryDataProvider(
|
||||||
props: ParentProps<{
|
props: ParentProps<{
|
||||||
directory: string
|
directory: string | Accessor<string>
|
||||||
draftID?: string
|
draftID?: string
|
||||||
server?: ServerConnection.Key
|
server?: Accessor<ServerConnection.Key | undefined>
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -25,17 +25,17 @@ export function DirectoryDataProvider(
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const language = useLanguage()
|
const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
|
||||||
const directory = () => props.directory
|
|
||||||
const slug = createMemo(() => base64Encode(directory()))
|
const slug = createMemo(() => base64Encode(directory()))
|
||||||
const href = (sessionID: string) => {
|
const href = (sessionID: string) => {
|
||||||
if (props.server) return sessionHref(props.server, sessionID)
|
const server = props.server?.()
|
||||||
|
if (server) return sessionHref(server, sessionID)
|
||||||
return `/${slug()}/session/${sessionID}`
|
return `/${slug()}/session/${sessionID}`
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
|
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
|
||||||
if (props.draftID || props.server) return
|
if (props.draftID || props.server?.()) return
|
||||||
const next = sync().data.path.directory
|
const next = sync().data.path.directory
|
||||||
if (!next || next === directory()) return
|
if (!next || next === directory()) return
|
||||||
const path = location.pathname.slice(slug().length + 1)
|
const path = location.pathname.slice(slug().length + 1)
|
||||||
@@ -45,8 +45,8 @@ export function DirectoryDataProvider(
|
|||||||
createResource(
|
createResource(
|
||||||
() => params.id,
|
() => params.id,
|
||||||
(id) =>
|
(id) =>
|
||||||
serverSync()
|
sync()
|
||||||
.session.hydrate(id)
|
.session.sync(id)
|
||||||
.catch(() => {}),
|
.catch(() => {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export function Home() {
|
|||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
class="h-full [container-type:size]"
|
class="h-full [container-type:size]"
|
||||||
thumbContainer={scroll.viewport.thumbTrack()}
|
thumbContainer={scroll.viewport.thumbTrack}
|
||||||
thumbHoverTarget={scroll.viewport.hoverTarget()}
|
thumbHoverTarget={scroll.viewport.hoverTarget}
|
||||||
viewportRef={scroll.viewport.setViewport}
|
viewportRef={scroll.viewport.setViewport}
|
||||||
onScroll={(event) => scroll.viewport.update(event.currentTarget.scrollTop)}
|
onScroll={(event) => scroll.viewport.update(event.currentTarget.scrollTop)}
|
||||||
onWheel={scroll.viewport.containOuterWheel}
|
onWheel={scroll.viewport.containOuterWheel}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
|
import { type Accessor, createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||||
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
||||||
@@ -29,16 +29,16 @@ const projectContextMenuID = (server: ServerConnection.Any, directory: string) =
|
|||||||
|
|
||||||
export type HomeProjectsViewProps = {
|
export type HomeProjectsViewProps = {
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
servers: ServerConnection.Any[]
|
servers: Accessor<ServerConnection.Any[]>
|
||||||
projects: LocalProject[]
|
projects: Accessor<LocalProject[]>
|
||||||
recentlyClosed: LocalProject[]
|
recentlyClosed: Accessor<LocalProject[]>
|
||||||
selection: HomeProjectSelection
|
selection: Accessor<HomeProjectSelection>
|
||||||
homedir: string
|
homedir: Accessor<string>
|
||||||
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
|
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
|
||||||
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
|
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
|
||||||
collapsed: (server: ServerConnection.Any) => boolean
|
collapsed: (server: ServerConnection.Any) => boolean
|
||||||
canDefaultServer: boolean
|
canDefaultServer: Accessor<boolean>
|
||||||
defaultServerKey: ServerConnection.Key | null | undefined
|
defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
|
||||||
canRevealProject: (server: ServerConnection.Any) => boolean
|
canRevealProject: (server: ServerConnection.Any) => boolean
|
||||||
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
|
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
|
||||||
onWheel: (event: WheelEvent) => void
|
onWheel: (event: WheelEvent) => void
|
||||||
@@ -81,7 +81,9 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
|||||||
>
|
>
|
||||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||||
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||||
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
|
<Show
|
||||||
|
when={props.servers().length === 1 && !(props.projects().length === 0 && props.recentlyClosed().length > 0)}
|
||||||
|
>
|
||||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||||
<IconButtonV2
|
<IconButtonV2
|
||||||
data-action="home-add-project"
|
data-action="home-add-project"
|
||||||
@@ -89,8 +91,8 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
|||||||
size="large"
|
size="large"
|
||||||
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||||
icon={<IconV2 name="folder-add-left" />}
|
icon={<IconV2 name="folder-add-left" />}
|
||||||
disabled={props.serverHealth(props.servers[0])?.healthy === false}
|
disabled={props.serverHealth(props.servers()[0])?.healthy === false}
|
||||||
onClick={() => props.onChooseProject(props.servers[0])}
|
onClick={() => props.onChooseProject(props.servers()[0])}
|
||||||
aria-label={props.language.t("home.project.add")}
|
aria-label={props.language.t("home.project.add")}
|
||||||
/>
|
/>
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
@@ -98,20 +100,25 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
|
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
|
||||||
<Show
|
<Show
|
||||||
when={props.servers.length > 1}
|
when={props.servers().length > 1}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="pr-3">
|
<div class="pr-3">
|
||||||
<Show
|
<Show
|
||||||
when={props.projects.length > 0}
|
when={props.projects().length > 0}
|
||||||
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
|
fallback={<HomeProjectEmpty {...props} server={props.servers()[0]} items={props.recentlyClosed()} />}
|
||||||
>
|
>
|
||||||
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
|
<HomeProjectList
|
||||||
|
{...props}
|
||||||
|
{...contextMenuProps}
|
||||||
|
server={props.servers()[0]}
|
||||||
|
items={props.projects()}
|
||||||
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 flex-col gap-4 pr-3">
|
<div class="flex min-w-0 flex-col gap-4 pr-3">
|
||||||
<For each={props.servers}>
|
<For each={props.servers()}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const projects = () => props.projectsForServer(item)
|
const projects = () => props.projectsForServer(item)
|
||||||
const healthy = () => !!props.serverHealth(item)?.healthy
|
const healthy = () => !!props.serverHealth(item)?.healthy
|
||||||
@@ -123,7 +130,7 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
|||||||
server={item}
|
server={item}
|
||||||
{...props}
|
{...props}
|
||||||
{...contextMenuProps}
|
{...contextMenuProps}
|
||||||
selected={props.selection.server === ServerConnection.key(item) && !props.selection.directory}
|
selected={props.selection().server === ServerConnection.key(item) && !props.selection().directory}
|
||||||
collapsed={collapsed()}
|
collapsed={collapsed()}
|
||||||
health={props.serverHealth(item)}
|
health={props.serverHealth(item)}
|
||||||
/>
|
/>
|
||||||
@@ -270,8 +277,8 @@ function HomeServerRow(props: {
|
|||||||
<ServerRowMenuView
|
<ServerRowMenuView
|
||||||
server={props.server}
|
server={props.server}
|
||||||
labels={serverMenuLabels(props.language)}
|
labels={serverMenuLabels(props.language)}
|
||||||
canDefault={props.canDefaultServer}
|
canDefault={props.canDefaultServer()}
|
||||||
isDefault={props.defaultServerKey === ServerConnection.key(props.server)}
|
isDefault={props.defaultServerKey() === ServerConnection.key(props.server)}
|
||||||
canRemove={props.canRemoveServer(props.server)}
|
canRemove={props.canRemoveServer(props.server)}
|
||||||
onEdit={props.onEditServer}
|
onEdit={props.onEditServer}
|
||||||
onSetDefault={() => props.onSetDefaultServer(props.server)}
|
onSetDefault={() => props.onSetDefaultServer(props.server)}
|
||||||
@@ -332,7 +339,7 @@ function HomeProjectList(props: HomeProjectListProps) {
|
|||||||
const source = event.operation.source
|
const source = event.operation.source
|
||||||
if (event.canceled || !isSortable(source)) return
|
if (event.canceled || !isSortable(source)) return
|
||||||
if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index)
|
if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index)
|
||||||
if (props.selection.server !== ServerConnection.key(props.server))
|
if (props.selection().server !== ServerConnection.key(props.server))
|
||||||
props.onSelectProject(props.server, source.id.toString())
|
props.onSelectProject(props.server, source.id.toString())
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -343,7 +350,7 @@ function HomeProjectList(props: HomeProjectListProps) {
|
|||||||
row's sortable unregisters on unmount) and discarding animations.
|
row's sortable unregisters on unmount) and discarding animations.
|
||||||
String keys keep row elements alive and move them on reorder. */}
|
String keys keep row elements alive and move them on reorder. */}
|
||||||
<For each={props.items.map((project) => project.worktree)}>
|
<For each={props.items.map((project) => project.worktree)}>
|
||||||
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index()} />}
|
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
@@ -353,7 +360,7 @@ function HomeProjectList(props: HomeProjectListProps) {
|
|||||||
function HomeProjectSlot(
|
function HomeProjectSlot(
|
||||||
props: HomeProjectListProps & {
|
props: HomeProjectListProps & {
|
||||||
worktree: string
|
worktree: string
|
||||||
index: number
|
index: () => number
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const initial = props.items.find((item) => item.worktree === props.worktree)
|
const initial = props.items.find((item) => item.worktree === props.worktree)
|
||||||
@@ -369,9 +376,10 @@ function HomeProjectSlot(
|
|||||||
project={project()}
|
project={project()}
|
||||||
server={props.server}
|
server={props.server}
|
||||||
index={props.index}
|
index={props.index}
|
||||||
serverSelected={props.selection.server === ServerConnection.key(props.server)}
|
serverSelected={props.selection().server === ServerConnection.key(props.server)}
|
||||||
selected={
|
selected={
|
||||||
props.selection.server === ServerConnection.key(props.server) && props.selection.directory === props.worktree
|
props.selection().server === ServerConnection.key(props.server) &&
|
||||||
|
props.selection().directory === props.worktree
|
||||||
}
|
}
|
||||||
unseen={props.unseenCount(props.server, project())}
|
unseen={props.unseenCount(props.server, project())}
|
||||||
/>
|
/>
|
||||||
@@ -417,7 +425,7 @@ function HomeRecentlyClosedRow(
|
|||||||
) {
|
) {
|
||||||
const unreachable = () => props.serverHealth(props.server)?.healthy === false
|
const unreachable = () => props.serverHealth(props.server)?.healthy === false
|
||||||
const path = () => {
|
const path = () => {
|
||||||
const home = props.homedir
|
const home = props.homedir()
|
||||||
const worktree = props.project.worktree
|
const worktree = props.project.worktree
|
||||||
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
|
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
|
||||||
return worktree
|
return worktree
|
||||||
@@ -443,7 +451,7 @@ function HomeProjectRow(
|
|||||||
HomeProjectsContextMenuProps & {
|
HomeProjectsContextMenuProps & {
|
||||||
project: LocalProject
|
project: LocalProject
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
index: number
|
index: () => number
|
||||||
serverSelected: boolean
|
serverSelected: boolean
|
||||||
selected: boolean
|
selected: boolean
|
||||||
unseen: number
|
unseen: number
|
||||||
@@ -456,7 +464,7 @@ function HomeProjectRow(
|
|||||||
return props.project.worktree
|
return props.project.worktree
|
||||||
},
|
},
|
||||||
get index() {
|
get index() {
|
||||||
return props.index
|
return props.index()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
let pointerDownSelected: boolean | undefined
|
let pointerDownSelected: boolean | undefined
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
|
|||||||
return (
|
return (
|
||||||
<HomeProjectsView
|
<HomeProjectsView
|
||||||
language={props.projects.copy.language}
|
language={props.projects.copy.language}
|
||||||
servers={props.projects.server.list()}
|
servers={props.projects.server.list}
|
||||||
projects={props.projects.project.list()}
|
projects={props.projects.project.list}
|
||||||
recentlyClosed={props.projects.project.recentlyClosed()}
|
recentlyClosed={props.projects.project.recentlyClosed}
|
||||||
selection={props.projects.selection.value()}
|
selection={props.projects.selection.value}
|
||||||
homedir={props.projects.project.homedir()}
|
homedir={props.projects.project.homedir}
|
||||||
serverHealth={props.projects.server.health}
|
serverHealth={props.projects.server.health}
|
||||||
projectsForServer={props.projects.server.projects}
|
projectsForServer={props.projects.server.projects}
|
||||||
collapsed={props.projects.server.collapsed}
|
collapsed={props.projects.server.collapsed}
|
||||||
canDefaultServer={props.projects.server.canDefault()}
|
canDefaultServer={props.projects.server.canDefault}
|
||||||
defaultServerKey={props.projects.server.defaultKey()}
|
defaultServerKey={props.projects.server.defaultKey}
|
||||||
canRevealProject={props.projects.project.canReveal}
|
canRevealProject={props.projects.project.canReveal}
|
||||||
unseenCount={props.projects.project.unseenCount}
|
unseenCount={props.projects.project.unseenCount}
|
||||||
onWheel={props.scroll.viewport.containWheel}
|
onWheel={props.scroll.viewport.containWheel}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function createHomeSessionsController(home: HomeController) {
|
|||||||
}))
|
}))
|
||||||
const sessionLoad = useQuery(() => ({
|
const sessionLoad = useQuery(() => ({
|
||||||
queryKey: homeSessions().indexKey,
|
queryKey: homeSessions().indexKey,
|
||||||
enabled: home.server.focusedContext()?.sdk.connection.status() === "connected",
|
enabled: !!home.server.focusedContext(),
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
const ctx = home.server.focusedContext()
|
const ctx = home.server.focusedContext()
|
||||||
if (!ctx) return { sessions: [], eventSequence: 0 }
|
if (!ctx) return { sessions: [], eventSequence: 0 }
|
||||||
@@ -295,13 +295,13 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
|
|||||||
export type HomeSessionsController = ReturnType<typeof createHomeSessionsController>
|
export type HomeSessionsController = ReturnType<typeof createHomeSessionsController>
|
||||||
|
|
||||||
export function HomeSessionStatusController(props: {
|
export function HomeSessionStatusController(props: {
|
||||||
server: ServerConnection.Key
|
server: Accessor<ServerConnection.Key>
|
||||||
record: HomeSessionRecord
|
record: HomeSessionRecord
|
||||||
isOpenTab: (record: HomeSessionRecord) => boolean
|
isOpenTab: (record: HomeSessionRecord) => boolean
|
||||||
render: (state: { unread: Accessor<boolean>; loading: Accessor<boolean>; open: Accessor<boolean> }) => JSX.Element
|
render: (state: { unread: Accessor<boolean>; loading: Accessor<boolean>; open: Accessor<boolean> }) => JSX.Element
|
||||||
}) {
|
}) {
|
||||||
const avatar = useSessionTabAvatarState(
|
const avatar = useSessionTabAvatarState(
|
||||||
() => props.server,
|
props.server,
|
||||||
() => props.record.session.location.directory,
|
() => props.record.session.location.directory,
|
||||||
() => props.record.session.id,
|
() => props.record.session.id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import { createMemo, For, Show, Suspense } from "solid-js"
|
import { type Accessor, createMemo, For, Show, Suspense } from "solid-js"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
@@ -38,17 +38,17 @@ function isBackgroundOpen(event: MouseEvent) {
|
|||||||
|
|
||||||
export type HomeSessionsViewProps = {
|
export type HomeSessionsViewProps = {
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
groups: HomeSessionGroup[]
|
groups: Accessor<HomeSessionGroup[]>
|
||||||
showProjectName: boolean
|
showProjectName: Accessor<boolean>
|
||||||
server: ServerConnection.Key
|
server: Accessor<ServerConnection.Key>
|
||||||
canCreateSession: boolean
|
canCreateSession: Accessor<boolean>
|
||||||
searchValue: string
|
searchValue: Accessor<string>
|
||||||
searchPlaceholder: string
|
searchPlaceholder: Accessor<string>
|
||||||
searchOpen: boolean
|
searchOpen: Accessor<boolean>
|
||||||
searchLoading: boolean
|
searchLoading: Accessor<boolean>
|
||||||
searchResults: HomeSessionRecord[]
|
searchResults: Accessor<HomeSessionRecord[]>
|
||||||
searchActive: string
|
searchActive: Accessor<string>
|
||||||
searchNoResultsLabel: string
|
searchNoResultsLabel: Accessor<string>
|
||||||
titleOpacity: (id: HomeSessionGroup["id"]) => number
|
titleOpacity: (id: HomeSessionGroup["id"]) => number
|
||||||
isOpenTab: (record: HomeSessionRecord) => boolean
|
isOpenTab: (record: HomeSessionRecord) => boolean
|
||||||
onCreateSession: () => void
|
onCreateSession: () => void
|
||||||
@@ -81,7 +81,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
|||||||
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
|
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
|
||||||
<HomeSessionSearch {...props} />
|
<HomeSessionSearch {...props} />
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<Show when={props.groups.length > 0 && props.canCreateSession}>
|
<Show when={props.groups().length > 0 && props.canCreateSession()}>
|
||||||
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
||||||
<ButtonV2
|
<ButtonV2
|
||||||
data-action="home-new-session"
|
data-action="home-new-session"
|
||||||
@@ -113,16 +113,16 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={props.groups.length > 0}
|
when={props.groups().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<HomeSessionsEmpty
|
<HomeSessionsEmpty
|
||||||
onNewSession={props.canCreateSession ? props.onCreateSession : undefined}
|
onNewSession={props.canCreateSession() ? props.onCreateSession : undefined}
|
||||||
language={props.language}
|
language={props.language}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
|
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
|
||||||
<For each={props.groups}>
|
<For each={props.groups()}>
|
||||||
{(group, index) => (
|
{(group, index) => (
|
||||||
<>
|
<>
|
||||||
<HomeSessionGroupHeader
|
<HomeSessionGroupHeader
|
||||||
@@ -132,7 +132,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
|||||||
elevated={index() === 0}
|
elevated={index() === 0}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups.length - 1 ? "" : "mb-6"}`}
|
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups().length - 1 ? "" : "mb-6"}`}
|
||||||
>
|
>
|
||||||
<For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
|
<For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,7 +205,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
|||||||
return (
|
return (
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full">
|
<div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full">
|
||||||
<Show when={props.searchOpen}>
|
<Show when={props.searchOpen()}>
|
||||||
<div
|
<div
|
||||||
data-component="home-session-search-panel"
|
data-component="home-session-search-panel"
|
||||||
class={`
|
class={`
|
||||||
@@ -217,7 +217,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
|||||||
<div class="flex flex-col pt-9">
|
<div class="flex flex-col pt-9">
|
||||||
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
|
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
|
||||||
<Show
|
<Show
|
||||||
when={!props.searchLoading}
|
when={!props.searchLoading()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
|
<div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
|
||||||
<Spinner class="size-4" />
|
<Spinner class="size-4" />
|
||||||
@@ -225,7 +225,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={props.searchResults.length > 0}
|
when={props.searchResults().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<p
|
<p
|
||||||
class={`
|
class={`
|
||||||
@@ -233,7 +233,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
|||||||
text-v2-text-text-muted [font-weight:440]
|
text-v2-text-text-muted [font-weight:440]
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{props.searchNoResultsLabel}
|
{props.searchNoResultsLabel()}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -248,12 +248,12 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
|||||||
</p>
|
</p>
|
||||||
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
|
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
|
||||||
<div class="flex flex-col gap-px pb-2">
|
<div class="flex flex-col gap-px pb-2">
|
||||||
<For each={props.searchResults}>
|
<For each={props.searchResults()}>
|
||||||
{(record) => (
|
{(record) => (
|
||||||
<HomeSessionSearchResultRow
|
<HomeSessionSearchResultRow
|
||||||
{...props}
|
{...props}
|
||||||
record={record}
|
record={record}
|
||||||
selected={props.searchActive === homeSessionSearchKey(record)}
|
selected={props.searchActive() === homeSessionSearchKey(record)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -280,14 +280,16 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
|||||||
relative z-20 min-w-0 flex-1 border-0 bg-transparent outline-0
|
relative z-20 min-w-0 flex-1 border-0 bg-transparent outline-0
|
||||||
text-v2-text-text-base [font-weight:440] placeholder:text-v2-text-text-faint
|
text-v2-text-text-base [font-weight:440] placeholder:text-v2-text-text-faint
|
||||||
`}
|
`}
|
||||||
value={props.searchValue}
|
value={props.searchValue()}
|
||||||
placeholder={props.searchPlaceholder}
|
placeholder={props.searchPlaceholder()}
|
||||||
aria-label={props.searchPlaceholder}
|
aria-label={props.searchPlaceholder()}
|
||||||
aria-expanded={props.searchOpen}
|
aria-expanded={props.searchOpen()}
|
||||||
aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
|
aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
aria-activedescendant={
|
aria-activedescendant={
|
||||||
props.searchActive && props.searchOpen ? `home-session-search-option-${props.searchActive}` : undefined
|
props.searchActive() && props.searchOpen()
|
||||||
|
? `home-session-search-option-${props.searchActive()}`
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
onFocus={props.onSearchFocus}
|
onFocus={props.onSearchFocus}
|
||||||
onInput={(event) => props.onSearchInput(event.currentTarget.value)}
|
onInput={(event) => props.onSearchInput(event.currentTarget.value)}
|
||||||
@@ -298,7 +300,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
|||||||
event.currentTarget.blur()
|
event.currentTarget.blur()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!props.searchOpen || props.searchResults.length === 0) return
|
if (!props.searchOpen() || props.searchResults().length === 0) return
|
||||||
if (event.altKey || event.metaKey) return
|
if (event.altKey || event.metaKey) return
|
||||||
if (event.key === "ArrowDown") {
|
if (event.key === "ArrowDown") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -316,14 +318,14 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Show when={props.searchValue}>
|
<Show when={props.searchValue()}>
|
||||||
<IconButtonV2
|
<IconButtonV2
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
size="small"
|
size="small"
|
||||||
class="relative z-20 shrink-0"
|
class="relative z-20 shrink-0"
|
||||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||||
aria-label={props.searchPlaceholder}
|
aria-label={props.searchPlaceholder()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.onSearchClose()
|
props.onSearchClose()
|
||||||
props.onSearchFocus()
|
props.onSearchFocus()
|
||||||
@@ -343,7 +345,7 @@ function HomeSessionSearchResultRow(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const title = createMemo(() => sessionLabel(props.record.session))
|
const title = createMemo(() => sessionLabel(props.record.session))
|
||||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||||
const key = () => homeSessionSearchKey(props.record)
|
const key = () => homeSessionSearchKey(props.record)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -414,7 +416,7 @@ function HomeSessionGroupHeader(props: {
|
|||||||
|
|
||||||
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
||||||
const title = createMemo(() => sessionLabel(props.record.session))
|
const title = createMemo(() => sessionLabel(props.record.session))
|
||||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -11,17 +11,17 @@ export function HomeSessions(props: {
|
|||||||
return (
|
return (
|
||||||
<HomeSessionsView
|
<HomeSessionsView
|
||||||
language={props.sessions.copy.language}
|
language={props.sessions.copy.language}
|
||||||
groups={props.sessions.data.groups()}
|
groups={props.sessions.data.groups}
|
||||||
showProjectName={props.sessions.session.showProjectName()}
|
showProjectName={props.sessions.session.showProjectName}
|
||||||
server={props.sessions.session.server()}
|
server={props.sessions.session.server}
|
||||||
canCreateSession={props.sessions.session.canCreate()}
|
canCreateSession={props.sessions.session.canCreate}
|
||||||
searchValue={props.search.query.value()}
|
searchValue={props.search.query.value}
|
||||||
searchPlaceholder={props.search.query.placeholder()}
|
searchPlaceholder={props.search.query.placeholder}
|
||||||
searchOpen={props.search.query.open()}
|
searchOpen={props.search.query.open}
|
||||||
searchLoading={props.search.result.loading()}
|
searchLoading={props.search.result.loading}
|
||||||
searchResults={props.search.result.list()}
|
searchResults={props.search.result.list}
|
||||||
searchActive={props.search.result.active()}
|
searchActive={props.search.result.active}
|
||||||
searchNoResultsLabel={props.search.result.noResultsLabel()}
|
searchNoResultsLabel={props.search.result.noResultsLabel}
|
||||||
titleOpacity={props.scroll.header.titleOpacity}
|
titleOpacity={props.scroll.header.titleOpacity}
|
||||||
isOpenTab={props.sessions.tab.isOpen}
|
isOpenTab={props.sessions.tab.isOpen}
|
||||||
onCreateSession={props.sessions.session.create}
|
onCreateSession={props.sessions.session.create}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { onMount, Show, Suspense, type ParentProps } from "solid-js"
|
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { DebugBar } from "@/components/debug-bar"
|
import { DebugBar } from "@/components/debug-bar"
|
||||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||||
@@ -9,17 +9,15 @@ export default function Layout(props: ParentProps) {
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const [state, setState] = createStore({ debugTools: true })
|
const [state, setState] = createStore({ debugTools: true })
|
||||||
|
|
||||||
onMount(() => setV2Toast(true))
|
createEffect(() => setV2Toast(true))
|
||||||
|
|
||||||
const update: TitlebarUpdate = {
|
const update: TitlebarUpdate = {
|
||||||
get version() {
|
version: () => {
|
||||||
const state = platform.updater?.state()
|
const state = platform.updater?.state()
|
||||||
if (state?.status !== "ready") return undefined
|
if (state?.status !== "ready") return
|
||||||
return state.version
|
return state.version
|
||||||
},
|
},
|
||||||
get installing() {
|
installing: () => platform.updater?.state().status === "installing",
|
||||||
return platform.updater?.state().status === "installing"
|
|
||||||
},
|
|
||||||
install: () => void platform.updater?.install(),
|
install: () => void platform.updater?.install(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,9 +40,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||||
<Suspense>{props.children}</Suspense>
|
<Suspense>{props.children}</Suspense>
|
||||||
</main>
|
</main>
|
||||||
<Show when={import.meta.env.DEV && state.debugTools}>
|
{import.meta.env.DEV && state.debugTools && <DebugBar inline />}
|
||||||
<DebugBar inline />
|
|
||||||
</Show>
|
|
||||||
<ToastRegion v2 />
|
<ToastRegion v2 />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createMemo, type Accessor } from "solid-js"
|
|||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { sessionPermissionRequest, sessionQuestionForm } from "@/pages/session/composer/session-request-tree"
|
import { sessionPermissionRequest, sessionQuestionRequest } from "@/pages/session/composer/session-request-tree"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
|
|
||||||
export function useSessionTabAvatarState(
|
export function useSessionTabAvatarState(
|
||||||
@@ -31,7 +31,7 @@ export function useSessionTabAvatarState(
|
|||||||
const serverSync = sync()
|
const serverSync = sync()
|
||||||
if (!serverSync) return false
|
if (!serverSync) return false
|
||||||
const [store] = serverSync.child(directory(), { bootstrap: false })
|
const [store] = serverSync.child(directory(), { bootstrap: false })
|
||||||
return !!sessionQuestionForm(store.session, serverSync.session.data.form, sessionId())
|
return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId())
|
||||||
})
|
})
|
||||||
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
|
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
|
||||||
const notificationState = createMemo(() => {
|
const notificationState = createMemo(() => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user