Compare commits

..
1 Commits
Author SHA1 Message Date
Kit Langton ff09490bcc fix(core): retain locations while sessions are executing 2026-09-06 10:14:33 -04:00
182 changed files with 1661 additions and 7588 deletions
+4 -2
View File
@@ -432,7 +432,6 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"app-builder-lib": "26.15.7",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "42.10.1",
@@ -1037,9 +1036,12 @@
},
},
"trustedDependencies": [
"electron",
"esbuild",
"tree-sitter-powershell",
"protobufjs",
"electron",
"web-tree-sitter",
"tree-sitter-bash",
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-T1JUv8gUrXavDI1HdVGqNRal4y2Bh5QavpR4DV+bJdA=",
"aarch64-linux": "sha256-wlbWdEUVWAafgxaZis0Q+SAas8k6hu3s0C39CnMZ2gw=",
"aarch64-darwin": "sha256-74rRue8KQWKzgMMb540beqMzp1DUShlPte6qklYCVTs=",
"x86_64-darwin": "sha256-ihr3DoLd/4Lw6WbyXzSQXMP4hrVGk/UiRkO2aoQn8vg="
"x86_64-linux": "sha256-bWKV3fV8Yc+3ILaNRChIYajsRcvEG2OkydsD2KMUXCM=",
"aarch64-linux": "sha256-zIavB09LW+BU3GOq0PO7Lqfc3MVchkiOUf3trnAjhRM=",
"aarch64-darwin": "sha256-Jlvz7QsXsb8GS9Ty4TmUIfGOawiueZWWEYmyEPms3dc=",
"x86_64-darwin": "sha256-TzTDtcDtj1c3+eP2WZAHOfkszxIS06J6QUwlnscLaUM="
}
}
+4
View File
@@ -146,6 +146,10 @@
"esbuild",
"node-pty",
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron"
],
"overrides": {
+1 -2
View File
@@ -81,7 +81,7 @@ export const route = Route.make({
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider owns a small `Route.make(...)` composition instead of a protocol clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
When a provider supports multiple physical transports, selection remains execution policy below its semantic route. `OpenResponsesChannel.transport(...)` owns the provider-neutral Responses WebSocket concept: it prepares one final request, executes HTTP by default, strips WebSocket-disallowed fields, and passes a generic channel exchange to a per-call `WebSocketChannelExecutor` when supplied. Provider-specific Responses routes opt in with handshake and connection-age policy. `Route.streamPrepared` owns decoding and acknowledges channel completion only after successful full consumption.
@@ -115,7 +115,6 @@ Keep provider facades small and explicit:
- Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`.
- Resolve `apiKey``Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash).
- Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`.
- Give every named provider its own file and top-level export. Keep its endpoint, auth defaults, and route setup in that file. Compose shared protocols directly; do not nest named provider presets under generic compatible facades or keep their endpoints in a shared provider profile registry.
`Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
+15 -28
View File
@@ -1,6 +1,6 @@
# @opencode-ai/ai
Schema-first language model and image-generation APIs built with Effect.
Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code.
```ts
import { Effect, Layer } from "effect"
@@ -255,7 +255,7 @@ over the same implementation, including the legacy live `requests` array. New te
## Provider compaction
Compaction is opt-in. The package supports automatic compaction in OpenAI/Azure Responses and Anthropic Messages (including Claude on Vertex), and explicit compaction calls in OpenAI/Azure/xAI Responses. Model and deployment support still depends on the provider.
Compaction is opt-in. The package supports automatic compaction in OpenAI/Azure Responses and Anthropic Messages (including Claude on Vertex), and explicit compaction calls in OpenAI/Azure/xAI Responses. Model and deployment support still depends on the provider. Bedrock compaction is deferred to a separate follow-up.
This is different from prompt caching, server-side history storage, or truncation. Compaction returns provider-owned context that must be replayed to continue the conversation.
@@ -298,7 +298,7 @@ result.responseID
result.usage
```
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request, with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Request metadata, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request. It follows the [Codex V2 request shape](https://github.com/openai/codex/blob/728cb12/codex-rs/core/src/compact_remote_v2_attempt.rs), with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Session/cache identifiers, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
Only a successful `response.completed` with a response ID and exactly one logical encrypted checkpoint succeeds. Repeated item events are correlated by ID/output slot, including ID-less checkpoints. Other output is ignored, not returned as assistant text or dispatched as tools. Failed, incomplete, malformed, and interrupted responses return errors rather than partial checkpoints.
@@ -372,7 +372,9 @@ providerOptions: {
- Anthropic can return a compaction block with `content: null` when summarization fails. This becomes a compaction part with `text: null`, which is **not** a successful replacement for prior history. The package never prunes history automatically.
- `Usage` totals include all reported Anthropic `usage.iterations`, including compaction. `contextTokens` separately reports the final message iteration's inclusive input size, when available. A compaction-only pause does not report a post-compaction context size. Raw iteration usage remains in `providerMetadata`.
### Recording tests
### Ownership and verification
The AI package transports options and typed conversation parts. It does not schedule compaction, persist Session checkpoints, select history, switch providers, or replace Core's existing local compaction policy. Native compaction is not enabled for OpenCode Sessions by this feature; Session integration must persist these parts before enabling it. The AI SDK bridge rejects native compaction parts rather than dropping them. Provider-executed tool APIs and persistence changes are a separate follow-up.
Tests cover serialized round trips, real local HTTP plus a tool loop, WebSocket recovery, provider errors, malformed blocks, and usage accounting. Live provider tests are gated by `RECORD=true` and the relevant API keys:
@@ -391,7 +393,7 @@ Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "aut
### Auto placement
`"auto"` places up to four breakpoints — the last tool definition, the first system part, the last system part when distinct, and the final message boundary. These expose successively larger reusable prefixes for tool definitions, system instructions, and the active conversation. The rolling final-message boundary advances on every request so recent conversation prefixes remain reusable during tool loops.
`"auto"` places up to four breakpoints — the last tool definition, the first system part, the last system part when distinct, and the final message boundary. These expose successively larger reusable prefixes for tools, the base agent, project instructions, and the active conversation. The rolling final-message boundary advances on every request so recent conversation prefixes remain reusable during tool loops.
Tools precede every system and conversation block in the provider prefix, so tool definitions must remain byte-stable and deterministically ordered for downstream breakpoints to remain reusable.
@@ -459,33 +461,22 @@ const gateway = CloudflareAIGateway.configure({
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
```
Included LLM providers: OpenAI, Anthropic, Google (Gemini), Google Vertex, Amazon Bedrock, Azure OpenAI, Baseten, Cerebras, Cloudflare AI Gateway, Cloudflare Workers AI, DeepInfra, DeepSeek, Fireworks, Groq, Mistral, OpenRouter, TogetherAI, and xAI. Z.ai currently exposes image generation. Generic Chat Completions, Responses, and Anthropic Messages-compatible entrypoints support custom endpoints.
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
Each named provider owns its module, endpoint, authentication, and route setup. Providers with the same wire format compose the shared protocol directly:
### Package-like entrypoints
```ts
import { DeepSeek, Fireworks } from "@opencode-ai/ai/providers"
const deepseek = DeepSeek.configure({ apiKey }).model("deepseek-chat")
const fireworks = Fireworks.configure({ apiKey }).model("accounts/fireworks/models/my-model")
```
The former `OpenAICompatible.baseten`, `.cerebras`, `.deepinfra`, `.deepseek`, `.fireworks`, `.groq`, and `.togetherai` presets are replaced by the top-level `Baseten`, `Cerebras`, `DeepInfra`, `DeepSeek`, `Fireworks`, `Groq`, and `TogetherAI` exports. Use `CloudflareAIGateway` and `CloudflareWorkersAI` directly; each has its own module. `OpenAICompatible` configures generic endpoints with an explicit `baseURL`.
### Provider entrypoints
Provider modules are available through dedicated exports from `@opencode-ai/ai`. Each LLM entrypoint exports `model(modelID, settings)`, where `settings` contains provider configuration plus common `headers` and `body` overlays.
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers` and `body` overlays.
```ts
import { model } from "@opencode-ai/ai/providers/openai/responses"
const selected = model("gpt-5", {
apiKey: process.env.OPENAI_API_KEY,
headers: { "x-application": "example" },
headers: { "x-application": "opencode" },
})
```
APIs have separate entrypoints:
OpenAI Chat and OpenAI Responses are separate semantic entrypoints:
- `@opencode-ai/ai/providers/openai/chat`
- `@opencode-ai/ai/providers/openai/responses`
@@ -526,13 +517,9 @@ import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
model("claude-sonnet-4-6", { project: "my-project", location: "global" })
```
Additional provider entrypoints include:
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path.
- `@opencode-ai/ai/providers/baseten`
- `@opencode-ai/ai/providers/deepseek`
- `@opencode-ai/ai/providers/fireworks`
- `@opencode-ai/ai/providers/cloudflare-ai-gateway`
- `@opencode-ai/ai/providers/cloudflare-workers-ai`
Other provider exports listed above remain direct facades until they explicitly implement the package-like contract. Exporting a provider facade does not implicitly make it a catalog-loadable provider package.
## Provider options & HTTP overlays
@@ -559,7 +546,7 @@ LLM.request({
## Routes
Compose a route with `Route.make({ protocol, endpoint, auth, framing, ... })`. The route owns endpoint/auth/framing and the protocol owns body construction plus stream parsing. Transports receive the route's endpoint and auth when preparing requests. Unsupported request shapes fail during protocol lowering.
Adding a new model or deployment is usually 5-15 lines using `Route.make({ protocol, endpoint, auth, framing, ... })`. The route owns endpoint/auth/framing and the protocol owns body construction plus stream parsing. Transports are reusable IO templates that receive route endpoint/auth at compile time. Capability/catalog metadata lives outside this low-level package; unsupported request shapes fail during protocol lowering. See `AGENTS.md` for the architectural detail.
## Effect
+5 -6
View File
@@ -7,8 +7,7 @@ import { AwsV4Signer } from "aws4fetch"
import { Config, ConfigProvider, Effect, FileSystem, PlatformError, Redacted } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
import * as ProviderShared from "../src/protocols/shared"
import { CloudflareAIGateway } from "../src/providers/cloudflare-ai-gateway.js"
import { CloudflareWorkersAI } from "../src/providers/cloudflare-workers-ai.js"
import * as Cloudflare from "../src/providers/cloudflare"
type Provider = {
readonly id: string
@@ -121,11 +120,11 @@ const PROVIDERS: ReadonlyArray<Provider> = [
],
validate: (env) =>
validateChat({
url: `${CloudflareAIGateway.baseURL({
url: `${Cloudflare.aiGatewayBaseURL({
accountId: env.CLOUDFLARE_ACCOUNT_ID,
gatewayId: env.CLOUDFLARE_GATEWAY_ID || undefined,
})}/chat/completions`,
token: Redacted.make(envValue(env, CloudflareAIGateway.authEnvVars)),
token: Redacted.make(envValue(env, Cloudflare.aiGatewayAuthEnvVars)),
tokenHeader: "cf-aig-authorization",
model: "workers-ai/@cf/meta/llama-3.1-8b-instruct",
}),
@@ -141,8 +140,8 @@ const PROVIDERS: ReadonlyArray<Provider> = [
],
validate: (env) =>
validateChat({
url: `${CloudflareWorkersAI.baseURL({ accountId: env.CLOUDFLARE_ACCOUNT_ID })}/chat/completions`,
token: Redacted.make(envValue(env, CloudflareWorkersAI.authEnvVars)),
url: `${Cloudflare.workersAIBaseURL({ accountId: env.CLOUDFLARE_ACCOUNT_ID })}/chat/completions`,
token: Redacted.make(envValue(env, Cloudflare.workersAIAuthEnvVars)),
model: "@cf/meta/llama-3.1-8b-instruct",
}),
},
@@ -415,7 +415,10 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
// System prompts share the cache-point convention: emit the text block, then
// optionally a positional `cachePoint` marker.
const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArray<LLMRequest["system"][number]>) => {
const lowerSystem = (
breakpoints: BedrockCache.Breakpoints,
system: ReadonlyArray<LLMRequest["system"][number]>,
) => {
const content = system
.filter((part) => part.text.length > 0)
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
@@ -428,7 +431,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
const generation = request.generation
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints(request.model.id)
const breakpoints = BedrockCache.breakpoints()
const toolConfig = (() => {
if (flattened.tools.length === 0) return undefined
return {
-8
View File
@@ -639,14 +639,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
: state.usage,
}
const candidate = event.candidates?.[0]
if (candidate?.finishReason && mapFinishReason(candidate.finishReason, state.hasToolCalls) === "error")
return Effect.fail(
ProviderShared.eventError(
state.route,
`Gemini stopped with ${candidate.finishReason}`,
ProviderShared.encodeJson(event),
),
)
if (!candidate?.content)
return Effect.succeed([
{ ...nextState, finishReason: candidate?.finishReason ?? nextState.finishReason },
-12
View File
@@ -9,8 +9,6 @@ import {
AIError,
InvalidProviderOutputError,
LLMEvent,
ProviderInternalError,
UnknownProviderError,
Usage,
type FinishReasonDetails,
type LLMRequest,
@@ -702,16 +700,6 @@ const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event:
normalized: mapFinishReason(choice.finish_reason),
raw: choice.finish_reason,
}
if (finishReason.normalized === "error") {
const details = {
message: `Mistral Chat stopped with ${finishReason.raw}`,
body: ProviderShared.encodeJson(event),
}
return yield* new AIError({
reason:
finishReason.raw === "network_error" ? new ProviderInternalError(details) : new UnknownProviderError(details),
})
}
const incomplete = finishReason.normalized === "length" || finishReason.normalized === "content-filter"
if (!incomplete && Object.keys(withTools.pendingTools).length > 0)
return yield* ProviderShared.eventError(
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import type { CacheHint } from "../../schema/index.js"
import { newBreakpoints, ttlBucket } from "./cache.js"
import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache.js"
// Bedrock cache markers are positional: emit a `cachePoint` block immediately
// after the content the caller wants treated as a cacheable prefix. Bedrock
@@ -13,46 +13,24 @@ export const CachePointBlock = Schema.Struct({
})
export type CachePointBlock = Schema.Schema.Type<typeof CachePointBlock>
const LEGACY_CLAUDE = ["anthropic.claude-instant", "anthropic.claude-v1", "anthropic.claude-v2", "anthropic.claude-3-"]
// These legacy Claude releases support explicit caching, but only for five minutes.
const CLAUDE_5M = [
"anthropic.claude-3-5-sonnet-20241022-v2:0",
"anthropic.claude-3-5-haiku-20241022-v1:0",
"anthropic.claude-3-7-sonnet-20250219-v1:0",
"anthropic.claude-sonnet-4-20250514-v1:0",
"anthropic.claude-opus-4-20250514-v1:0",
"anthropic.claude-opus-4-1-20250805-v1:0",
]
// Callers share the four-breakpoint budget across system, messages, and tools.
// Callers pass a shared counter through every `block()` call site so the
// four-breakpoint budget is respected across `system`, `messages`, and `tools`.
export const BEDROCK_BREAKPOINT_CAP = 4
export const breakpoints = (modelID: string) => {
// Substring matching also handles regional prefixes and model-bearing ARNs.
const short = CLAUDE_5M.some((id) => modelID.includes(id))
return {
...newBreakpoints(BEDROCK_BREAKPOINT_CAP),
// Assume modern Claude releases retain caching support; older generations need an explicit exception.
// Other model families use implicit caching where available.
supported: modelID.includes("anthropic.claude-") && (short || !LEGACY_CLAUDE.some((id) => modelID.includes(id))),
ttl1h: !short,
}
}
export type Breakpoints = ReturnType<typeof breakpoints>
export type { Breakpoints } from "./cache.js"
export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP)
const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } }
const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } }
export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => {
if (!breakpoints.supported) return undefined
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
if (breakpoints.remaining <= 0) {
breakpoints.dropped += 1
return undefined
}
breakpoints.remaining -= 1
return breakpoints.ttl1h && ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
}
export * as BedrockCache from "./bedrock-cache.js"
-60
View File
@@ -1,60 +0,0 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("baseten")
const baseURL = "https://inference.baseten.co/v1"
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = Route.make({
id: "baseten-chat",
provider: id,
providerMetadataKey: "baseten",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
})
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: endpoint ?? baseURL },
auth: AuthOptions.bearer(input, "BASETEN_API_KEY"),
})
return {
id,
model: (modelID: string | ModelID) => configured.model<OpenAIProviderOptionsInput>({ id: modelID }),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as Baseten from "./baseten.js"
+7 -11
View File
@@ -1,13 +1,12 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { profiles } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cerebras")
const baseURL = "https://api.cerebras.ai/v1"
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
@@ -21,22 +20,19 @@ export interface Settings extends ProviderPackage.Settings {
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = Route.make({
export const route = OpenAICompatibleChat.route.with({
id: "cerebras-chat",
provider: id,
providerMetadataKey: "cerebras",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
endpoint: { baseURL: profiles.cerebras.baseURL },
})
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: endpoint ?? baseURL },
endpoint: { baseURL: baseURL ?? profiles.cerebras.baseURL },
auth: AuthOptions.bearer(input, "CEREBRAS_API_KEY"),
})
return {
@@ -1,98 +0,0 @@
import type { Config, Redacted } from "effect"
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { Auth } from "../route/auth.js"
import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cloudflare-ai-gateway")
export const authEnvVars = ["CLOUDFLARE_API_TOKEN", "CF_AIG_TOKEN"] as const
type GatewayURL = AtLeastOne<{
readonly accountId: string
readonly baseURL: string
}> & {
readonly gatewayId?: string
}
export type LanguageModelOptions = GatewayURL &
Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
/** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */
readonly gatewayApiKey?: string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
GatewayURL & {
readonly apiKey?: string
readonly gatewayApiKey?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const baseURL = (input: GatewayURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
}
const auth = (input: LanguageModelOptions) => {
if ("auth" in input && input.auth) return input.auth
const gateway = Auth.optional(input.gatewayApiKey, "gatewayApiKey")
.orElse(Auth.config(authEnvVars[0]))
.orElse(Auth.config(authEnvVars[1]))
.pipe(Auth.bearerHeader("cf-aig-authorization"))
if (!("apiKey" in input) || input.apiKey === undefined) return gateway
if (input.gatewayApiKey === undefined) return Auth.bearer(input.apiKey)
return Auth.bearerHeader("cf-aig-authorization", input.gatewayApiKey).andThen(Auth.bearer(input.apiKey))
}
export const route = Route.make({
id: "cloudflare-ai-gateway",
provider: id,
providerMetadataKey: "cloudflare-ai-gateway",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions"),
framing: OpenAIChat.framing,
})
export const routes = [route]
export const configure = (input: LanguageModelOptions) => {
const {
accountId: _accountId,
gatewayId: _gatewayId,
apiKey: _apiKey,
gatewayApiKey: _gatewayApiKey,
baseURL: _baseURL,
auth: _auth,
...defaults
} = input
const configured = route.with({
...defaults,
endpoint: { baseURL: baseURL(input) },
auth: auth(input),
})
return {
id,
model: (modelID: string | ModelID) => configured.model<OpenAIProviderOptionsInput>({ id: modelID }),
configure,
}
}
export const provider = { id, configure }
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
gatewayApiKey: settings.gatewayApiKey,
baseURL: baseURL(settings),
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as CloudflareAIGateway from "./cloudflare-ai-gateway.js"
@@ -1,71 +0,0 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cloudflare-workers-ai")
export const authEnvVars = ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN"] as const
type WorkersAIURL = AtLeastOne<{
readonly accountId: string
readonly baseURL: string
}>
export type LanguageModelOptions = WorkersAIURL &
Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
WorkersAIURL & {
readonly apiKey?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const baseURL = (input: WorkersAIURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
}
export const route = Route.make({
id: "cloudflare-workers-ai",
provider: id,
providerMetadataKey: "cloudflare-workers-ai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions"),
framing: OpenAIChat.framing,
})
export const routes = [route]
export const configure = (input: LanguageModelOptions) => {
const { accountId: _accountId, apiKey: _apiKey, auth: _auth, baseURL: _baseURL, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: baseURL(input) },
auth: AuthOptions.bearer(input, authEnvVars),
})
return {
id,
model: (modelID: string | ModelID) => configured.model<OpenAIProviderOptionsInput>({ id: modelID }),
configure,
}
}
export const provider = { id, configure }
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: baseURL(settings),
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as CloudflareWorkersAI from "./cloudflare-workers-ai.js"
+133
View File
@@ -0,0 +1,133 @@
import type { Config, Redacted } from "effect"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
import { Auth } from "../route/auth.js"
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway")
export const workersAIID = ProviderID.make("cloudflare-workers-ai")
export const aiGatewayAuthEnvVars = ["CLOUDFLARE_API_TOKEN", "CF_AIG_TOKEN"] as const
export const workersAIAuthEnvVars = ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN"] as const
type CloudflareSecret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
type GatewayURL = AtLeastOne<{
readonly accountId: string
readonly baseURL: string
}> & {
readonly gatewayId?: string
}
export type AIGatewayOptions = GatewayURL &
Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
/** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */
readonly gatewayApiKey?: CloudflareSecret
readonly providerOptions?: OpenAIProviderOptionsInput
}
type WorkersAIURL = AtLeastOne<{
readonly accountId: string
readonly baseURL: string
}>
export type WorkersAIOptions = WorkersAIURL &
Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const aiGatewayBaseURL = (input: GatewayURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
}
const aiGatewayAuth = (input: AIGatewayOptions) => {
if ("auth" in input && input.auth) return input.auth
const gateway = Auth.optional(input.gatewayApiKey, "gatewayApiKey")
.orElse(Auth.config("CLOUDFLARE_API_TOKEN"))
.orElse(Auth.config("CF_AIG_TOKEN"))
.pipe(Auth.bearerHeader("cf-aig-authorization"))
if (!("apiKey" in input) || input.apiKey === undefined) return gateway
if (input.gatewayApiKey === undefined) return Auth.bearer(input.apiKey)
return Auth.bearerHeader("cf-aig-authorization", input.gatewayApiKey).andThen(Auth.bearer(input.apiKey))
}
export const workersAIBaseURL = (input: WorkersAIURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
}
const workersAIAuth = (input: WorkersAIOptions) => {
return AuthOptions.bearer(input, workersAIAuthEnvVars)
}
export const aiGatewayRoute = OpenAICompatibleChat.route.with({
id: "cloudflare-ai-gateway",
provider: aiGatewayID,
})
export const workersAIRoute = OpenAICompatibleChat.route.with({
id: "cloudflare-workers-ai",
provider: workersAIID,
})
export const routes = [aiGatewayRoute, workersAIRoute]
const aiGatewayDefaults = (options: AIGatewayOptions) => {
const {
accountId: _accountId,
gatewayId: _gatewayId,
apiKey: _apiKey,
gatewayApiKey: _gatewayApiKey,
baseURL: _baseURL,
auth: _auth,
...rest
} = options
return rest
}
const workersAIDefaults = (options: WorkersAIOptions) => {
const { accountId: _accountId, apiKey: _apiKey, auth: _auth, baseURL: _baseURL, ...rest } = options
return rest
}
const configureAIGateway = (options: AIGatewayOptions) => {
const route = aiGatewayRoute.with({
...aiGatewayDefaults(options),
endpoint: { baseURL: aiGatewayBaseURL(options) },
auth: aiGatewayAuth(options),
})
return {
id: aiGatewayID,
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
configure: configureAIGateway,
}
}
const configureWorkersAI = (options: WorkersAIOptions) => {
const route = workersAIRoute.with({
...workersAIDefaults(options),
endpoint: { baseURL: workersAIBaseURL(options) },
auth: workersAIAuth(options),
})
return {
id: workersAIID,
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
configure: configureWorkersAI,
}
}
export const CloudflareAIGateway = {
id: aiGatewayID,
configure: configureAIGateway,
}
export const CloudflareWorkersAI = {
id: workersAIID,
configure: configureWorkersAI,
}
+8 -12
View File
@@ -1,13 +1,12 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { profiles } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("deepinfra")
const baseURL = "https://api.deepinfra.com/v1/openai"
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
@@ -21,24 +20,21 @@ export interface Settings extends ProviderPackage.Settings {
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = Route.make({
export const route = OpenAICompatibleChat.route.with({
id: "deepinfra-chat",
provider: id,
providerMetadataKey: "deepinfra",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
endpoint: { baseURL: profiles.deepinfra.baseURL },
})
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
const root = endpoint?.replace(/\/+$/, "")
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const root = baseURL?.replace(/\/+$/, "")
const configured = route.with({
...defaults,
endpoint: {
baseURL: root === undefined ? baseURL : root.endsWith("/openai") ? root : `${root}/openai`,
baseURL: root === undefined ? profiles.deepinfra.baseURL : root.endsWith("/openai") ? root : `${root}/openai`,
},
auth: AuthOptions.bearer(input, "DEEPINFRA_API_KEY"),
})
-64
View File
@@ -1,64 +0,0 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("deepseek")
const baseURL = "https://api.deepseek.com/v1"
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = Route.make({
id: "deepseek-chat",
provider: id,
providerMetadataKey: "deepseek",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
})
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: endpoint ?? baseURL },
auth: AuthOptions.bearer(input, "DEEPSEEK_API_KEY"),
})
return {
id,
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_tokens", supportsStore: false },
}),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as DeepSeek from "./deepseek.js"
-60
View File
@@ -1,60 +0,0 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("fireworks")
const baseURL = "https://api.fireworks.ai/inference/v1"
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = Route.make({
id: "fireworks-chat",
provider: id,
providerMetadataKey: "fireworks",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
})
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: endpoint ?? baseURL },
auth: AuthOptions.bearer(input, "FIREWORKS_API_KEY"),
})
return {
id,
model: (modelID: string | ModelID) => configured.model<OpenAIProviderOptionsInput>({ id: modelID }),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as Fireworks from "./fireworks.js"
@@ -1,7 +1,6 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
@@ -25,13 +24,10 @@ export interface Settings extends ProviderPackage.Settings {
readonly providerOptions?: OpenAIProviderOptionsInput
}
const route = Route.make({
const route = OpenAICompatibleChat.route.with({
id: "google-vertex-chat",
provider: id,
providerMetadataKey: "vertex",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions"),
framing: OpenAIChat.framing,
})
export const routes = [route]
@@ -1,7 +1,6 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
@@ -25,14 +24,11 @@ export interface Settings extends ProviderPackage.Settings {
readonly providerOptions?: OpenResponsesProviderOptionsInput
}
const route = Route.make({
const route = OpenAICompatibleResponses.route.with({
id: "google-vertex-responses",
provider: id,
providerMetadataKey: "vertex",
protocol: OpenResponses.protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: OpenResponses.httpTransport,
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
providerOptions: { store: false },
})
export const routes = [route]
+4 -4
View File
@@ -7,10 +7,10 @@ import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { ProviderID, type ModelID, type LLMRequest } from "../schema/index.js"
import { profiles } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("groq")
const baseURL = "https://api.groq.com/openai/v1"
export type ProviderOptions = Pick<OpenAIProviderOptionsInput, "reasoningEffort"> & {
/** Controls visible reasoning on GPT-OSS; other models always use parsed reasoning. */
@@ -73,15 +73,15 @@ export const route = Route.make({
provider: id,
providerMetadataKey: "openai",
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
endpoint: Endpoint.path("/chat/completions", { baseURL: profiles.groq.baseURL }),
framing: OpenAIChat.framing,
})
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: endpoint ?? baseURL },
endpoint: { baseURL: baseURL ?? profiles.groq.baseURL },
auth: AuthOptions.bearer(input, "GROQ_API_KEY"),
})
return {
+2 -5
View File
@@ -3,13 +3,10 @@ export * as AnthropicCompatible from "./anthropic-compatible.js"
export * as AmazonBedrock from "./amazon-bedrock.js"
export * as AmazonBedrockMantle from "./amazon-bedrock-mantle.js"
export * as Azure from "./azure.js"
export * as Baseten from "./baseten.js"
export * as Cerebras from "./cerebras.js"
export * as CloudflareAIGateway from "./cloudflare-ai-gateway.js"
export * as CloudflareWorkersAI from "./cloudflare-workers-ai.js"
export * as Cloudflare from "./cloudflare.js"
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare.js"
export * as DeepInfra from "./deepinfra.js"
export * as DeepSeek from "./deepseek.js"
export * as Fireworks from "./fireworks.js"
export * as Google from "./google.js"
export * as GoogleVertex from "./google-vertex.js"
export * as GoogleVertexChat from "./google-vertex-chat.js"
@@ -0,0 +1,20 @@
export interface OpenAICompatibleProfile {
readonly provider: string
readonly baseURL: string
}
export const profiles = {
baseten: { provider: "baseten", baseURL: "https://inference.baseten.co/v1" },
cerebras: { provider: "cerebras", baseURL: "https://api.cerebras.ai/v1" },
deepinfra: { provider: "deepinfra", baseURL: "https://api.deepinfra.com/v1/openai" },
deepseek: { provider: "deepseek", baseURL: "https://api.deepseek.com/v1" },
fireworks: { provider: "fireworks", baseURL: "https://api.fireworks.ai/inference/v1" },
groq: { provider: "groq", baseURL: "https://api.groq.com/openai/v1" },
openrouter: { provider: "openrouter", baseURL: "https://openrouter.ai/api/v1" },
togetherai: { provider: "togetherai", baseURL: "https://api.together.xyz/v1" },
xai: { provider: "xai", baseURL: "https://api.x.ai/v1" },
} as const satisfies Record<string, OpenAICompatibleProfile>
export const byProvider: Record<string, OpenAICompatibleProfile> = Object.fromEntries(
Object.values(profiles).map((profile) => [profile.provider, profile]),
)
+31 -2
View File
@@ -1,8 +1,9 @@
import { ProviderID, type ModelID } from "../schema/index.js"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("openai-compatible")
@@ -21,6 +22,12 @@ export interface Settings extends ProviderPackage.Settings {
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const routes = [OpenAICompatibleChat.route]
export const configure = (input: GenericModelOptions) => {
@@ -40,6 +47,22 @@ export const configure = (input: GenericModelOptions) => {
}
}
const define = (profile: OpenAICompatibleProfile) => {
const configureProfile = (input: FamilyModelOptions = {}) => {
const facade = configure({
...input,
baseURL: input.baseURL ?? profile.baseURL,
provider: profile.provider,
})
return {
id: ProviderID.make(profile.provider),
model: facade.model,
configure: configureProfile,
}
}
return configureProfile()
}
export const provider = {
id,
configure,
@@ -55,4 +78,10 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
providerOptions: settings.providerOptions,
}).model(modelID)
export * as OpenAICompatible from "./openai-compatible.js"
export const baseten = define(profiles.baseten)
export const cerebras = define(profiles.cerebras)
export const deepinfra = define(profiles.deepinfra)
export const deepseek = define(profiles.deepseek)
export const fireworks = define(profiles.fireworks)
export const groq = define(profiles.groq)
export const togetherai = define(profiles.togetherai)
+8 -7
View File
@@ -5,12 +5,13 @@ import { Protocol } from "../route/protocol.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js"
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
import { isRecord } from "../protocols/shared.js"
export const id = ProviderID.make("openrouter")
const baseURL = "https://openrouter.ai/api/v1"
export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider)
const ADAPTER = "openrouter"
type OpenRouterString<Known extends string> = Known | (string & {})
@@ -161,20 +162,20 @@ const bodyOptions = (input: unknown) => {
export const route = Route.make({
id: ADAPTER,
provider: id,
provider: profile.provider,
providerMetadataKey: "openrouter",
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }),
framing: OpenAIChat.framing,
})
export const routes = [route]
const configuredRoute = (input: LanguageModelOptions) => {
const { apiKey: _, auth: _auth, baseURL: endpoint, ...rest } = input
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return route.with({
...rest,
endpoint: { baseURL: endpoint ?? baseURL },
endpoint: { baseURL: baseURL ?? profile.baseURL },
auth: AuthOptions.bearer(input, "OPENROUTER_API_KEY"),
})
}
+7 -11
View File
@@ -1,13 +1,12 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { profiles } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("togetherai")
const baseURL = "https://api.together.xyz/v1"
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
@@ -21,22 +20,19 @@ export interface Settings extends ProviderPackage.Settings {
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = Route.make({
export const route = OpenAICompatibleChat.route.with({
id: "togetherai-chat",
provider: id,
providerMetadataKey: "togetherai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
endpoint: { baseURL: profiles.togetherai.baseURL },
})
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: endpoint ?? baseURL },
endpoint: { baseURL: baseURL ?? profiles.togetherai.baseURL },
auth: AuthOptions.bearer(input, ["TOGETHER_API_KEY", "TOGETHER_AI_API_KEY"]),
})
return {
+11 -10
View File
@@ -2,7 +2,9 @@ import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import { OpenResponsesChannel } from "../protocols/open-responses-channel.js"
import { XAIResponses } from "../protocols/xai-responses.js"
import { XAIImages } from "../protocols/xai-images.js"
@@ -10,7 +12,6 @@ import type { OpenAIOptionsInput } from "./openai-options.js"
import type { ProviderPackage } from "../provider-package.js"
export const id = ProviderID.make("xai")
const baseURL = "https://api.x.ai/v1"
export type XAIProviderOptionsInput = OpenAIOptionsInput & { readonly contextManagement?: never }
@@ -36,7 +37,7 @@ const responsesRoute = Route.make({
provider: id,
providerMetadataKey: "xai",
protocol: XAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL }),
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenResponsesChannel.transport({
id: "openai-responses",
name: "xAI Responses",
@@ -50,8 +51,8 @@ const chatRoute = Route.make({
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAICompatibleChat.route.transport,
headers: ({ request }): Record<string, string> =>
request.promptCacheKey ? { "x-grok-conv-id": request.promptCacheKey } : {},
})
@@ -61,19 +62,19 @@ export const routes = [responsesRoute, chatRoute]
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
const configuredResponsesRoute = (input: LanguageModelOptions) => {
const { apiKey: _, auth: _auth, baseURL: endpoint, ...rest } = input
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return responsesRoute.with({
...rest,
endpoint: { baseURL: endpoint ?? baseURL },
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
})
}
const configuredChatRoute = (input: LanguageModelOptions) => {
const { apiKey: _, auth: _auth, baseURL: endpoint, ...rest } = input
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return chatRoute.with({
...rest,
endpoint: { baseURL: endpoint ?? baseURL },
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
})
}
@@ -87,7 +88,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
XAIImages.model({
id: modelID,
auth: auth(input),
baseURL: input.baseURL ?? baseURL,
baseURL: input.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
@@ -20,7 +20,7 @@ export interface WebSocketChannelExchange {
readonly connect: {
readonly url: string
readonly headers: Headers.Headers
/** Provider-safe connection age after which the channel executor should reconnect before sending. */
/** Provider-safe connection age after which Core should rotate before sending. */
readonly rotateAfterMs?: number
}
readonly fallback: () => Stream.Stream<string, AIError>
+5 -6
View File
@@ -6,8 +6,7 @@ import * as AmazonBedrock from "../src/providers/amazon-bedrock.js"
import * as Anthropic from "../src/providers/anthropic.js"
import * as AnthropicCompatible from "../src/providers/anthropic-compatible.js"
import * as Azure from "../src/providers/azure.js"
import { CloudflareWorkersAI } from "../src/providers/cloudflare-workers-ai.js"
import { DeepSeek } from "../src/providers/deepseek.js"
import * as Cloudflare from "../src/providers/cloudflare.js"
import * as Google from "../src/providers/google.js"
import * as GoogleVertex from "../src/providers/google-vertex.js"
import * as GoogleVertexChat from "../src/providers/google-vertex-chat.js"
@@ -264,10 +263,10 @@ XAI.configure({ apiKey: "xai-key" }).responses("grok-4", {})
// @ts-expect-error xAI Chat selectors only accept model ids.
XAI.configure({ apiKey: "xai-key" }).chat("grok-4", {})
DeepSeek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat")
OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat")
// @ts-expect-error OpenAI-compatible family selectors only accept model ids.
DeepSeek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat", {})
OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat", {})
CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama")
Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama")
// @ts-expect-error Cloudflare Workers AI model selectors only accept model ids.
CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama", {})
Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama", {})
+1 -10
View File
@@ -3,11 +3,8 @@ import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@o
import { Route, Protocol, WebSocketTransport } from "@opencode-ai/ai/route"
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
import {
Baseten,
CloudflareAIGateway,
CloudflareWorkersAI,
DeepSeek,
Fireworks,
OpenAI,
OpenAICompatible,
OpenRouter,
@@ -51,13 +48,7 @@ describe("public exports", () => {
expect(OpenAI.model).toBeFunction()
expect(OpenAI.provider.responses).toBe(OpenAI.responses)
expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction()
for (const provider of [Baseten, DeepSeek, Fireworks]) {
expect(provider.configure).toBeFunction()
expect(provider.model).toBeFunction()
}
for (const name of ["baseten", "cerebras", "deepinfra", "deepseek", "fireworks", "groq", "togetherai"]) {
expect(OpenAICompatible).not.toHaveProperty(name)
}
expect(OpenAICompatible.deepseek.model).toBeFunction()
expect(
OpenAICompatibleResponses.configure({ baseURL: "https://responses.test/v1" }).model("fixture").route.id,
).toBe("openai-compatible-responses")
@@ -1,7 +1,7 @@
import { LLM } from "../../src/index.js"
import { OpenAICompatible } from "../../src/providers.js"
const model = OpenAICompatible.configure({ baseURL: "https://compatible.example/v1" }).model("test-model")
const model = OpenAICompatible.deepseek.model("deepseek-chat")
LLM.request({ model, prompt: "Hello", providerOptions: { store: false } })
-26
View File
@@ -32,11 +32,6 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/ai/providers/cerebras"),
import("@opencode-ai/ai/providers/deepinfra"),
import("@opencode-ai/ai/providers/groq"),
import("@opencode-ai/ai/providers/baseten"),
import("@opencode-ai/ai/providers/deepseek"),
import("@opencode-ai/ai/providers/fireworks"),
import("@opencode-ai/ai/providers/cloudflare-ai-gateway"),
import("@opencode-ai/ai/providers/cloudflare-workers-ai"),
])
for (const module of modules) expect(module.model).toBeFunction()
@@ -65,27 +60,6 @@ describe("provider package entrypoints", () => {
expect(deepinfra.route.defaults.http?.body).toEqual(settings.body)
})
test("maps Cloudflare package settings onto provider-owned models", async () => {
const modules = await Promise.all([
import("@opencode-ai/ai/providers/cloudflare-ai-gateway"),
import("@opencode-ai/ai/providers/cloudflare-workers-ai"),
])
for (const provider of modules) {
const selected = provider.model("provider-model", {
accountId: "account",
apiKey: "fixture",
headers: { "x-application": "opencode" },
body: { custom: true },
providerOptions: { reasoningEffort: "high" },
})
expect(selected.provider).toBe(provider.id)
expect(selected.route.endpoint.baseURL).toBe(provider.baseURL({ accountId: "account" }))
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ custom: true })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
}
})
test("maps OpenRouter and xAI package settings onto executable models", async () => {
const OpenRouter = await import("@opencode-ai/ai/providers/openrouter")
const XAI = await import("@opencode-ai/ai/providers/xai")
@@ -1,119 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, Message, ToolCallPart } from "../../src/index.js"
import { AmazonBedrock } from "../../src/providers.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
const bedrock = AmazonBedrock.configure({ apiKey: "fixture" })
describe("Bedrock Converse cache policy", () => {
for (const id of [
"deepseek.r1-v1:0",
"meta.llama3-3-70b-instruct-v1:0",
"mistral.mistral-large-2402-v1:0",
"qwen.qwen3-coder-480b-a35b-v1:0",
"openai.gpt-oss-120b-1:0",
"cohere.command-r-v1:0",
"anthropic.claude-instant-v1",
"anthropic.claude-v1",
"anthropic.claude-v2",
"anthropic.claude-v2:1",
"anthropic.claude-3-haiku-20240307-v1:0",
"anthropic.claude-3-sonnet-20240229-v1:0",
"anthropic.claude-3-opus-20240229-v1:0",
"anthropic.claude-3-5-sonnet-20240620-v1:0",
"amazon.nova-lite-v1:0",
"global.amazon.nova-2-lite-v1:0",
"custom-model",
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123",
]) {
for (const policy of [undefined, "auto", "none", { tools: true, system: true, messages: { tail: 3 } }] as const) {
it.effect(`omits checkpoints for ${id} (${JSON.stringify(policy)})`, () =>
Effect.gen(function* () {
// Exercise both automatic placement and manual hints at every lowering site.
const cache = policy === "none" ? new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) : undefined
const prepared = yield* compileRequest(
LLM.request({
model: bedrock.model(id),
cache: policy,
system: [{ type: "text", text: "System prefix", cache }],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" }, cache }],
messages: [
Message.user([{ type: "text", text: "Question", cache }]),
Message.system([{ type: "text", text: "Update", cache }]),
Message.assistant([
{ type: "text", text: "Answer", cache },
{ type: "reasoning", text: "Unsigned reasoning", cache },
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
]),
Message.tool({ id: "call_1", name: "lookup", result: "Result", cache }),
],
}),
)
expect(JSON.stringify(prepared.body)).not.toContain("cachePoint")
expect(prepared.body).toMatchObject({
modelId: id,
system: [{ text: "System prefix" }],
toolConfig: { tools: [{ toolSpec: { name: "lookup" } }] },
messages: [
{ role: "user", content: [{ text: "Question" }, { text: "<system-update>\nUpdate\n</system-update>" }] },
{
role: "assistant",
content: [{ text: "Answer" }, { text: "Unsigned reasoning" }, { toolUse: { name: "lookup" } }],
},
{ role: "user", content: [{ toolResult: { content: [{ json: "Result" }] } }] },
],
})
}),
)
}
}
for (const [id, ttl] of [
["anthropic.claude-3-5-sonnet-20241022-v2:0", undefined],
["us.anthropic.claude-3-5-haiku-20241022-v1:0", undefined],
["eu.anthropic.claude-3-7-sonnet-20250219-v1:0", undefined],
["apac.anthropic.claude-sonnet-4-20250514-v1:0", undefined],
["anthropic.claude-opus-4-20250514-v1:0", undefined],
["anthropic.claude-opus-4-1-20250805-v1:0", undefined],
["anthropic.claude-sonnet-4-5-20250929-v1:0", "1h"],
["global.anthropic.claude-sonnet-99", "1h"],
["anthropic.claude-new-family-99", "1h"],
["arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6", "1h"],
["arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.anthropic.claude-sonnet-4-6", "1h"],
] as const) {
for (const ttlSeconds of [undefined, 3600]) {
it.effect(`preserves Claude checkpoints for ${id} (TTL: ${ttlSeconds ?? "default"})`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: bedrock.model(id),
system: [
{ type: "text", text: "Agent" },
{ type: "text", text: "Project" },
],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
prompt: "Question",
cache:
ttlSeconds === undefined ? undefined : { tools: true, system: true, messages: { tail: 1 }, ttlSeconds },
}),
)
const marker = {
cachePoint: ttlSeconds === undefined || ttl === undefined ? { type: "default" } : { type: "default", ttl },
}
expect(prepared.body).toMatchObject({
modelId: id,
toolConfig: { tools: [{ toolSpec: { name: "lookup" } }, marker] },
system: [{ text: "Agent" }, marker, { text: "Project" }, marker],
messages: [{ role: "user", content: [{ text: "Question" }, marker] }],
})
if (ttlSeconds === undefined || ttl === undefined)
expect(JSON.stringify(prepared.body)).not.toContain('"ttl"')
}),
)
}
}
})
@@ -136,7 +136,7 @@ const captureHeaders = (target: LanguageModel) =>
const model = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
apiKey: "test-bearer",
}).model("anthropic.claude-sonnet-4-5-20250929-v1:0")
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
const baseRequest = LLM.request({
id: "req_1",
@@ -155,7 +155,7 @@ describe("Bedrock Converse route", () => {
const prepared = yield* compileRequest(baseRequest)
expect(prepared.body).toEqual({
modelId: "anthropic.claude-sonnet-4-5-20250929-v1:0",
modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
system: [{ text: "You are concise." }],
messages: [{ role: "user", content: [{ text: "Say hello." }] }],
inferenceConfig: { maxTokens: 64, temperature: 0 },
+1 -2
View File
@@ -2,8 +2,7 @@ import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent } from "../../src/index.js"
import { CloudflareAIGateway } from "../../src/providers/cloudflare-ai-gateway.js"
import { CloudflareWorkersAI } from "../../src/providers/cloudflare-workers-ai.js"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
+6 -21
View File
@@ -1643,14 +1643,19 @@ describe("Gemini route", () => {
}),
)
it.effect("preserves blocking finishes and rejects invalid-output finish reasons", () =>
it.effect("maps current blocking and invalid-output finish reasons", () =>
Effect.gen(function* () {
const reasons = [
["MODEL_ARMOR", "content-filter"],
["IMAGE_PROHIBITED_CONTENT", "content-filter"],
["IMAGE_RECITATION", "content-filter"],
["LANGUAGE", "content-filter"],
["UNEXPECTED_TOOL_CALL", "error"],
["NO_IMAGE", "error"],
["IMAGE_OTHER", "unknown"],
["TOO_MANY_TOOL_CALLS", "error"],
["MISSING_THOUGHT_SIGNATURE", "error"],
["MALFORMED_RESPONSE", "error"],
] as const
for (const [raw, normalized] of reasons) {
@@ -1661,26 +1666,6 @@ describe("Gemini route", () => {
)
expect(response.finishReason).toEqual({ normalized, raw })
}
for (const raw of [
"MALFORMED_FUNCTION_CALL",
"UNEXPECTED_TOOL_CALL",
"NO_IMAGE",
"TOO_MANY_TOOL_CALLS",
"MISSING_THOUGHT_SIGNATURE",
"MALFORMED_RESPONSE",
]) {
const event = { candidates: [{ finishReason: raw, finishMessage: "Provider detail" }], responseId: "failure" }
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event))),
Effect.flip,
)
expect(error).toMatchObject({
_tag: "AI.Error",
reason: { _tag: "InvalidProviderOutput", body: JSON.stringify(event), http: { status: 200 } },
message: `Gemini stopped with ${raw}`,
})
}
}),
)
@@ -1,13 +1,7 @@
import * as Anthropic from "../../src/providers/anthropic.js"
import * as AnthropicCompatible from "../../src/providers/anthropic-compatible.js"
import {
Cerebras,
CloudflareAIGateway,
CloudflareWorkersAI,
DeepInfra,
DeepSeek,
TogetherAI,
} from "../../src/providers/index.js"
import { Cerebras, DeepInfra, TogetherAI } from "../../src/providers/index.js"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare.js"
import * as Google from "../../src/providers/google.js"
import * as OpenAI from "../../src/providers/openai.js"
import * as OpenAICompatible from "../../src/providers/openai-compatible.js"
@@ -51,18 +45,16 @@ const cloudflareAIGatewayWorkers = cloudflareAIGateway.model("workers-ai/@cf/met
const cloudflareAIGatewayWorkersTools = cloudflareAIGateway.model("workers-ai/@cf/openai/gpt-oss-20b")
const cloudflareWorkersAI = cloudflareWorkers.model("@cf/meta/llama-3.1-8b-instruct")
const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b")
const deepseek = DeepSeek.configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" }).model("deepseek-chat")
const deepseek = OpenAICompatible.deepseek
.configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" })
.model("deepseek-chat")
const together = TogetherAI.configure({
apiKey: process.env.TOGETHER_API_KEY ?? process.env.TOGETHER_AI_API_KEY ?? "fixture",
}).model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
const cerebras = Cerebras.configure({ apiKey: process.env.CEREBRAS_API_KEY ?? "fixture" }).model("gpt-oss-120b")
// These older cassettes exercise generic Chat compatibility. Native Groq request
// shaping and reasoning are covered by groq.recorded.test.ts.
const groq = OpenAICompatible.configure({
provider: "groq",
baseURL: "https://api.groq.com/openai/v1",
apiKey: process.env.GROQ_API_KEY ?? "fixture",
}).model("llama-3.3-70b-versatile")
const groq = OpenAICompatible.groq
.configure({ apiKey: process.env.GROQ_API_KEY ?? "fixture" })
.model("llama-3.3-70b-versatile")
const deepInfra = DeepInfra.configure({ apiKey: process.env.DEEPINFRA_API_KEY ?? "fixture" }).model(
"meta-llama/Llama-3.3-70B-Instruct-Turbo",
)
+1 -16
View File
@@ -600,6 +600,7 @@ describe("Mistral Chat", () => {
["stop", "stop"],
["model_length", "length"],
["tool_calls", "tool-calls"],
["error", "error"],
["future_reason", "unknown"],
] as const) {
const response = yield* LLMClient.generate(request).pipe(
@@ -608,22 +609,6 @@ describe("Mistral Chat", () => {
expect(response.finishReason).toEqual({ normalized, raw })
}
for (const [raw, tag] of [
["error", "UnknownProvider"],
["network_error", "ProviderInternal"],
] as const) {
const event = { ...chunk({}, raw), diagnostics: { trace: "failure" } }
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event))),
Effect.flip,
)
expect(error).toMatchObject({
_tag: "AI.Error",
reason: { _tag: tag, body: JSON.stringify(event), http: { status: 200 } },
message: `Mistral Chat stopped with ${raw}`,
})
}
const truncated = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
@@ -8,13 +8,10 @@ import {
Anthropic,
AnthropicCompatible,
Azure,
Baseten,
Cerebras,
CloudflareAIGateway,
CloudflareWorkersAI,
DeepInfra,
DeepSeek,
Fireworks,
Google,
GoogleVertex,
GoogleVertexChat,
@@ -60,9 +57,6 @@ describe("native OpenAI-compatible providers", () => {
"custom",
],
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
[Baseten.configure({ apiKey: "test" }).model("model"), "baseten"],
[DeepSeek.configure({ apiKey: "test" }).model("model"), "deepseek"],
[Fireworks.configure({ apiKey: "test" }).model("model"), "fireworks"],
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
@@ -93,36 +87,6 @@ describe("native OpenAI-compatible providers", () => {
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
})
it.effect("preserves extracted providers' Chat requests and identity through custom endpoints", () =>
Effect.gen(function* () {
for (const provider of [Baseten, DeepSeek, Fireworks]) {
const settings = {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
providerOptions: { reasoningEffort: "high" },
}
const selected = provider.configure(settings).model("test-model")
expect(selected.provider).toBe(provider.id)
expect(selected.route.id).toBe(`${provider.id}-chat`)
expect(selected.route.protocol).toBe("openai-chat")
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
const input = {
prompt: "Use a tool.",
generation: { maxTokens: 48 },
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}
const native = yield* compileRequest(LLM.request({ ...input, model: selected }))
const generic = yield* compileRequest(
LLM.request({
...input,
model: OpenAICompatible.configure({ ...settings, provider: provider.id }).model("test-model"),
}),
)
expect(native.body).toEqual(generic.body)
}
}),
)
test("preserves native DeepInfra provider and route identity", () => {
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
expect(deepinfra).toMatchObject({
@@ -200,7 +164,7 @@ describe("native OpenAI-compatible providers", () => {
})
test("maps package settings onto native executable models", () => {
for (const native of [Baseten, Cerebras, DeepSeek, Fireworks, TogetherAI]) {
for (const native of [TogetherAI, Cerebras]) {
const selected = native.model("provider-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
@@ -219,24 +183,6 @@ describe("native OpenAI-compatible providers", () => {
it.effect("resolves provider environment credentials and preserves deprecated Together credentials", () =>
Effect.gen(function* () {
const scenarios = [
{
model: Baseten.configure().model("model"),
env: { BASETEN_API_KEY: "baseten-secret" },
token: "baseten-secret",
url: "https://inference.baseten.co/v1/chat/completions",
},
{
model: DeepSeek.configure().model("deepseek-chat"),
env: { DEEPSEEK_API_KEY: "deepseek-secret" },
token: "deepseek-secret",
url: "https://api.deepseek.com/v1/chat/completions",
},
{
model: Fireworks.configure().model("model"),
env: { FIREWORKS_API_KEY: "fireworks-secret" },
token: "fireworks-secret",
url: "https://api.fireworks.ai/inference/v1/chat/completions",
},
{
model: TogetherAI.configure().model("llama"),
env: { TOGETHER_API_KEY: "together-primary", TOGETHER_AI_API_KEY: "together-legacy" },
@@ -4,6 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src/index.js"
import { Auth, LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import * as OpenAICompatible from "../../src/providers/openai-compatible.js"
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat.js"
import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
@@ -40,6 +41,15 @@ const usageChunk = (usage: object) => ({
usage,
})
const providerFamilies = [
["baseten", OpenAICompatible.baseten, "https://inference.baseten.co/v1"],
["cerebras", OpenAICompatible.cerebras, "https://api.cerebras.ai/v1"],
["deepinfra", OpenAICompatible.deepinfra, "https://api.deepinfra.com/v1/openai"],
["deepseek", OpenAICompatible.deepseek, "https://api.deepseek.com/v1"],
["fireworks", OpenAICompatible.fireworks, "https://api.fireworks.ai/inference/v1"],
["togetherai", OpenAICompatible.togetherai, "https://api.together.xyz/v1"],
] as const
describe("OpenAI-compatible Chat route", () => {
it.effect("prepares generic Chat target", () =>
Effect.gen(function* () {
@@ -81,6 +91,39 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
test("provides model helpers for compatible provider families", () => {
expect(
providerFamilies.map(([provider, family]) => {
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
return {
id: String(model.id),
provider: String(model.provider),
route: model.route.id,
baseURL: model.route.endpoint.baseURL,
}
}),
).toEqual(
providerFamilies.map(([provider, _, baseURL]) => ({
id: `${provider}-model`,
provider,
route: "openai-compatible-chat",
baseURL,
})),
)
const custom = OpenAICompatible.deepseek
.configure({
apiKey: "test-key",
baseURL: "https://custom.deepseek.test/v1",
})
.model("deepseek-chat")
expect(custom).toMatchObject({
provider: "deepseek",
route: { id: "openai-compatible-chat" },
})
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
})
it.effect("matches AI SDK compatible basic request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(request)
@@ -34,7 +34,7 @@ const observationFrame = (observation: ChannelObservation) => {
const terminal = (observation: ChannelObservation) => observation.type !== "frame"
// This channel fixture supports sequential test traffic.
// This deliberately models only sequential test traffic. Core owns production connection pooling and recovery.
const makeChannel = Effect.gen(function* () {
const constructor = yield* Socket.WebSocketConstructor
let connection: WebSocketConnection | undefined
@@ -1,264 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
story("maps grouped and collapsed switches to the timeline settings", async ({ mount }) => {
const component = await mount("settings-timeline-detail--interactive")
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const shell = component.getByRole("group", { name: "Shell", exact: true })
const grouped = shell.getByRole("switch", { name: "Shell grouped", exact: true })
const collapsed = shell.getByRole("switch", { name: "Shell collapsed", exact: true })
const value = component.locator('[data-slot="timeline-detail-fixture-value"]')
await expect(component.getByRole("switch")).toHaveCount(9)
await expect(component.getByText("Activity", { exact: true })).toHaveCount(0)
await expect(grouped).toBeChecked()
await expect(collapsed).toBeChecked()
await shell.locator('[data-field="placement"] [data-slot="switch-control"]').click()
await shell.locator('[data-field="details"] [data-slot="switch-control"]').click()
await expect(value).toContainText('"shell":{"placement":"separate","details":"expanded"}')
await grouped.focus()
await grouped.press("Space")
await collapsed.focus()
await collapsed.press("Space")
await expect(value).toContainText('"shell":{"placement":"grouped","details":"collapsed"}')
await expect(component.getByRole("group", { name: "Subagents", exact: true }).getByRole("switch")).toHaveCount(1)
})
story("replaces hidden switches with solid lines and restores options", async ({ mount, page }) => {
const component = await mount("settings-timeline-detail--interactive")
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const shell = component.getByRole("group", { name: "Shell", exact: true })
const visibility = shell.getByRole("button", { name: "Shell visibility" })
const label = shell.locator('[data-slot="timeline-detail-activity"] > label')
const color = await label.evaluate((element) => getComputedStyle(element).color)
const iconColor = await visibility.evaluate((element) => getComputedStyle(element).color)
await shell.locator('[data-field="placement"] [data-slot="switch-control"]').click()
await shell.locator('[data-field="details"] [data-slot="switch-control"]').click()
await visibility.hover()
await expect(page.getByRole("tooltip")).toHaveText("Hide")
expect(await page.getByRole("tooltip").evaluate((element) => element.getBoundingClientRect().bottom)).toBeLessThan(
await visibility.evaluate((element) => element.getBoundingClientRect().top),
)
await visibility.click()
await expect(visibility).toHaveAttribute("aria-pressed", "false")
await expect(shell.getByRole("switch")).toHaveCount(0)
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(2)
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]').first()).toHaveCSS(
"border-top-style",
"solid",
)
await expect(component.locator('[data-slot="timeline-detail-fixture-value"]')).toContainText(
'"shell":{"placement":"hidden","details":"expanded"}',
)
await expect(label).not.toHaveCSS("color", color)
await expect(visibility.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-eye-slash")
await component.getByRole("button", { name: "Advanced", exact: true }).hover()
await expect(visibility).not.toHaveCSS("color", iconColor)
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]').first()).toHaveCSS(
"border-top-color",
await visibility.evaluate((element) => getComputedStyle(element).color),
)
await visibility.hover()
await expect(page.getByRole("tooltip")).toHaveText("Show")
expect(await page.getByRole("tooltip").evaluate((element) => element.getBoundingClientRect().bottom)).toBeLessThan(
await visibility.evaluate((element) => element.getBoundingClientRect().top),
)
await visibility.click()
await expect(visibility).toHaveAttribute("aria-pressed", "true")
await expect(label).toHaveCSS("color", color)
await expect(visibility.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-eye")
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(0)
await expect(shell.getByRole("switch", { name: "Shell grouped", exact: true })).toBeEnabled()
await expect(shell.getByRole("switch", { name: "Shell collapsed", exact: true })).toBeEnabled()
await expect(shell.getByRole("switch", { name: "Shell grouped", exact: true })).not.toBeChecked()
await expect(shell.getByRole("switch", { name: "Shell collapsed", exact: true })).not.toBeChecked()
})
story("toggles visibility by clicking the activity label", async ({ mount }) => {
const component = await mount("settings-timeline-detail--interactive")
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const shell = component.getByRole("group", { name: "Shell", exact: true })
const label = shell.locator('[data-slot="timeline-detail-activity"] > label')
const visibility = shell.getByRole("button", { name: "Shell visibility" })
await expect(label).toHaveCSS("cursor", "default")
await label.click()
await expect(visibility).toHaveAttribute("aria-pressed", "false")
await expect(shell.getByRole("switch")).toHaveCount(0)
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(2)
await label.click()
await expect(visibility).toHaveAttribute("aria-pressed", "true")
await expect(shell.getByRole("switch", { name: "Shell grouped", exact: true })).toBeChecked()
await expect(shell.getByRole("switch", { name: "Shell collapsed", exact: true })).toBeChecked()
})
story("only highlights the eye when hovering the icon, not its activity label", async ({ mount }) => {
const component = await mount("settings-timeline-detail--interactive")
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const shell = component.getByRole("group", { name: "Shell", exact: true })
const visibility = shell.getByRole("button", { name: "Shell visibility" })
const label = shell.locator('[data-slot="timeline-detail-activity"] > label')
for (const hidden of [false, true]) {
if (hidden) await label.click()
await visibility.hover()
await expect(visibility).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
await label.hover()
await expect(visibility).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
}
})
story("opens advanced on returning to custom settings but not presets", async ({ mount }) => {
const component = await mount("settings-timeline-detail--interactive")
const advanced = component.getByRole("button", { name: "Advanced", exact: true })
await expect(advanced).toHaveAttribute("aria-expanded", "false")
await advanced.click()
await component.locator('[data-category="shell"][data-field="placement"] [data-slot="switch-control"]').click()
await expect(component.getByRole("slider")).toHaveAttribute("aria-valuetext", "Custom")
await advanced.click()
await expect(advanced).toHaveAttribute("aria-expanded", "false")
await component.getByRole("button", { name: "Leave settings" }).click()
await expect(advanced).toHaveCount(0)
await component.getByRole("button", { name: "Return to settings" }).click()
await expect(advanced).toHaveAttribute("aria-expanded", "true")
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).not.toBeChecked()
await component.getByRole("slider").press("End")
await expect(component.getByRole("slider")).toHaveAttribute("aria-valuetext", "Everything")
await component.getByRole("button", { name: "Leave settings" }).click()
await component.getByRole("button", { name: "Return to settings" }).click()
await expect(advanced).toHaveAttribute("aria-expanded", "false")
})
story("keeps visibility and switches in sync with the preset slider", async ({ mount }) => {
const component = await mount("settings-timeline-detail--interactive")
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const slider = component.getByRole("slider", { name: "Timeline detail" })
await slider.focus()
await slider.press("Home")
await expect(slider).toHaveAttribute("aria-valuetext", "Messages only")
await expect(component.getByRole("switch")).toHaveCount(0)
await expect(component.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(9)
await expect(component.locator('[data-action="timeline-detail-visibility"][aria-pressed="false"]')).toHaveCount(6)
await component.getByRole("button", { name: "Shell visibility" }).click()
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toBeChecked()
await expect(slider).toHaveAttribute("aria-valuetext", "Custom")
await slider.focus()
await slider.press("End")
await expect(slider).toHaveAttribute("aria-valuetext", "Everything")
await expect(component.getByRole("switch")).toHaveCount(9)
await expect(component.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(0)
await expect(component.locator('[data-action="timeline-detail-visibility"][aria-pressed="true"]')).toHaveCount(6)
await expect(component.getByRole("switch", { checked: true })).toHaveCount(0)
})
for (const direction of ["ltr", "rtl"]) {
for (const theme of ["light", "dark"]) {
story(`fits narrow and wide layouts in ${direction}, ${theme}`, async ({ mount, page }, testInfo) => {
await page.setViewportSize({ width: 900, height: 900 })
const component = await mount("settings-timeline-detail--interactive", { globals: { direction, theme } })
await expect(component.locator('[data-slot="timeline-detail-summary"]')).toHaveCSS(
"color",
await component
.locator('[data-slot="settings-row-title"]')
.evaluate((element) => getComputedStyle(element).color),
)
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const track = component.locator('[data-slot="timeline-detail-track"]')
await expect(track).toHaveCSS(
"--timeline-detail-track-background",
await track.evaluate(
(element, theme) =>
getComputedStyle(element)
.getPropertyValue(theme === "light" ? "--v2-background-bg-layer-04" : "--v2-background-bg-layer-03")
.trim(),
theme,
),
)
if (theme === "light") {
await expect(track.locator("span").first()).toHaveCSS("background-image", "none")
await expect(track).toHaveCSS(
"--timeline-detail-marker-background",
await track.evaluate((element) => getComputedStyle(element).getPropertyValue("--v2-grey-500").trim()),
)
}
if (theme === "dark") {
await expect(track.locator("span").first()).not.toHaveCSS("background-image", "none")
}
await page.screenshot({ path: testInfo.outputPath(`timeline-${theme}-${direction}.png`) })
const list = component.locator('[data-slot="timeline-detail-list"]')
await expect(component.locator('[data-slot="timeline-detail-categories"]')).toHaveCSS("margin-top", "0px")
for (const [column, field] of [
[2, "placement"],
[3, "details"],
] as const) {
const heading = await component
.locator(`[data-slot="timeline-detail-columns"] > :nth-child(${column})`)
.evaluate((element) => {
const rect = element.getBoundingClientRect()
return rect.x + rect.width / 2
})
const toggle = await component
.locator(`[data-category="shell"][data-field="${field}"] [data-slot="switch-control"]`)
.evaluate((element) => {
const rect = element.getBoundingClientRect()
return rect.x + rect.width / 2
})
expect(Math.abs(heading - toggle)).toBeLessThan(1)
}
await expect(component.locator('[data-slot="timeline-detail-activity"]').first()).toHaveCSS("gap", "12px")
for (const width of [900, 320]) {
await page.setViewportSize({ width, height: 900 })
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toBeVisible()
expect(await list.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
const visibility = component.getByRole("button", { name: "Shell visibility" })
await visibility.focus()
await visibility.press("Space")
await expect(visibility).toHaveAttribute("aria-pressed", "false")
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toHaveCount(0)
await expect(
component
.getByRole("group", { name: "Shell", exact: true })
.locator('[data-slot="timeline-detail-unavailable"]'),
).toHaveCount(2)
await visibility.press("Space")
await expect(visibility).toHaveAttribute("aria-pressed", "true")
const slider = component.getByRole("slider", { name: "Timeline detail" })
const track = component.locator('[data-slot="timeline-detail-track"]')
expect(await track.evaluate((element) => element.getBoundingClientRect().width)).toBe(
await component
.locator('[data-slot="timeline-detail-scale"]')
.evaluate((element) => element.getBoundingClientRect().width),
)
await slider.focus()
await slider.press("Home")
for (const position of [0, 1, 2, 3, 4]) {
if (position > 0) await slider.press("ArrowUp")
await expect(track).toHaveCSS("--timeline-detail-progress", `${position * 25}%`)
const fill = await track.evaluate((element) => {
const style = getComputedStyle(element, "::before")
return {
fraction: parseFloat(style.width) / element.getBoundingClientRect().width,
start: style.getPropertyValue("inset-inline-start"),
color: style.backgroundColor,
remainder: getComputedStyle(element).backgroundColor,
}
})
expect(fill.fraction).toBeCloseTo(position / 4, 2)
expect(fill.start).toBe("0px")
expect(fill.color).not.toBe(fill.remainder)
const marker = await track.locator("span").nth(position).boundingBox()
const bounds = await track.boundingBox()
expect(marker).not.toBeNull()
expect(bounds).not.toBeNull()
expect((marker!.x + marker!.width / 2 - bounds!.x) / bounds!.width).toBeCloseTo(
direction === "rtl" ? 1 - position / 4 : position / 4,
2,
)
}
}
})
}
}
@@ -53,7 +53,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(trigger).toBeEnabled()
await expect(trigger.locator("use")).toHaveAttribute(
"href",
`#opencode-v2-icon-${workspace ? "outline-worktree" : "monitor"}`,
`#opencode-v2-icon-${workspace ? "workspace-isolated" : "monitor"}`,
)
const background = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
await trigger.hover()
@@ -267,7 +267,7 @@ for (const state of ["closed", "unopened"] as const) {
messages.resolve()
await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible()
}
await expect(trigger.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-worktree")
await expect(trigger.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-workspace-isolated")
await trigger.click()
await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled()
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
@@ -23,21 +23,17 @@ test("changes timeline presets and saves custom thinking details", async ({ page
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
await expect(slider).toBeEnabled()
await slider.press("Home")
for (const [index, name] of ["Messages only", "Quiet", "Compact", "Detailed", "Everything"].entries()) {
for (const [index, name] of ["Everything", "Detailed", "Compact", "Quiet", "Text only"].entries()) {
if (index) await slider.press("ArrowRight")
await expect(slider).toHaveValue(String(index))
await expect(slider).toHaveAttribute("aria-valuetext", name)
}
await slider.press("End")
await slider.press("Home")
await settings.getByRole("button", { name: "Advanced", exact: true }).click()
const grouped = settings.getByRole("switch", { name: "Thinking grouped", exact: true })
const collapsed = settings.getByRole("switch", { name: "Thinking collapsed", exact: true })
await expect(grouped).not.toBeChecked()
await expect(collapsed).not.toBeChecked()
await settings.locator('[data-category="thinking"][data-field="placement"] [data-slot="switch-control"]').click()
await settings.locator('[data-category="thinking"][data-field="details"] [data-slot="switch-control"]').click()
await expect(grouped).toBeChecked()
await expect(collapsed).toBeChecked()
await settings.getByRole("button", { name: "Thinking Placement Separate", exact: true }).click()
await page.getByRole("option", { name: "Grouped", exact: true }).click()
await settings.getByRole("button", { name: "Thinking Details Expanded", exact: true }).click()
await page.getByRole("option", { name: "Collapsed", exact: true }).click()
await expect(slider).toHaveAttribute("aria-valuetext", "Custom")
await expect
.poll(() =>
@@ -154,8 +154,8 @@ test("worktree deletion sends the project location separately from the target",
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible()
await settings.getByRole("button", { name: "Delete workspace-1”?", exact: true }).click()
const confirmation = page.getByRole("dialog", { name: "Delete workspace-1”?", exact: true })
await settings.getByRole("button", { name: 'Delete worktree "workspace-1"?', exact: true }).click()
const confirmation = page.getByRole("dialog", { name: "Delete worktree", exact: true })
const remove = confirmation.getByRole("button", { name: "Delete worktree", exact: true })
await expect(remove).toBeEnabled()
const deleting = page.waitForRequest(
@@ -33,7 +33,7 @@ for (const viewport of [
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
await expect(settings).toBeFocused()
await page.setViewportSize(viewport)
await expect(slider).toHaveAccessibleDescription(/Choose how much detail appears in the session timeline/)
await expect(slider).toHaveAccessibleDescription(/Choose how much activity appears in the timeline/)
await expect.poll(() => main.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1)
// Wheel over the outer gutter must not move the entire settings screen.
-2
View File
@@ -7,7 +7,5 @@ export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./r
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
export { flushPersisted } from "./runtime/persistence/persist"
export { useWslServers } from "./servers/wsl/context"
export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types"
-2
View File
@@ -7,7 +7,6 @@ import { loadInitialLocale } from "@/runtime/i18n/language"
import { PlatformProvider } from "@/runtime/platform/platform"
import { createWebPlatform } from "@/runtime/platform/web"
import { isStandalone, PwaRoutePersistence, restorePwaRoute } from "@/runtime/platform/pwa"
import { KeyboardInsets } from "@/runtime/platform/keyboard"
import en from "@/runtime/i18n/en"
import zh from "@/runtime/i18n/zh"
import { authFromToken } from "@/runtime/server/api"
@@ -93,7 +92,6 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
>
<KeyboardInsets />
{standalone && <PwaRoutePersistence />}
</AppInterface>
</AppBaseProviders>
+15 -59
View File
@@ -34,8 +34,6 @@ export const ProviderTipSchema = Persistence.struct({
dismissedAt: Schema.Finite,
})
export const WorkspaceTipSchema = ProviderTipSchema
export function NewSessionView(props: {
composer: ComposerModel
project: PromptProjectController
@@ -101,15 +99,7 @@ export function NewSessionView(props: {
</div>
</div>
</div>
<NewSessionTips
workspaceEligible={
!!props.project.selected() &&
props.workspace.bar.visible() &&
props.workspace.selection.value() !== "create" &&
props.workspace.project.managed() === 0
}
onWorkspace={() => select("create")}
/>
<ProviderTip />
</div>
</div>
)
@@ -128,90 +118,56 @@ export function NewSessionStatus(props: { visible: boolean }) {
)
}
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
function ProviderTip() {
const language = useLanguage()
const dialog = useDialog()
const sdk = useWorkspaceLocation()
const providers = useProviders(() => sdk().directory)
const [providerState, setProviderState, , providerReady] = persisted(
const [persistedState, setPersistedState, , persistedReady] = persisted(
Persist.global("new-session.provider-tip"),
ProviderTipSchema,
{ dismissedAt: 0 },
)
const [workspaceState, setWorkspaceState, , workspaceReady] = persisted(
Persist.global("new-session.workspace-tip"),
WorkspaceTipSchema,
{ dismissedAt: 0 },
)
const workspaceVisible = createMemo(
() =>
props.workspaceEligible &&
workspaceReady() &&
Date.now() - workspaceState.dismissedAt >= providerTipDismissalDuration,
)
const providerVisible = createMemo(
const visible = createMemo(
() =>
providers.ready() &&
providerReady() &&
persistedReady() &&
providers.paid().length === 0 &&
Date.now() - providerState.dismissedAt >= providerTipDismissalDuration,
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
)
const tip = createMemo<"workspace" | "provider" | undefined>(() => {
if (providerVisible()) return "provider"
if (workspaceVisible()) return "workspace"
})
const displayed = createMemo<"workspace" | "provider" | undefined>((previous) => tip() ?? previous)
const [ref, setRef] = createSignal<HTMLDivElement>()
const presence = createPresence({
show: () => tip() !== undefined,
show: visible,
element: () => ref() ?? null,
})
const open = () => {
const current = tip()
if (!current) return
if (current === "workspace") {
setWorkspaceState("dismissedAt", Date.now())
props.onWorkspace()
return
}
const openProviders = () => {
void import("@/providers/connect/dialog").then(({ DialogConnectProvider }) => {
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
})
}
const dismiss = () => {
const current = tip()
if (!current) return
if (current === "workspace") {
setWorkspaceState("dismissedAt", Date.now())
return
}
setProviderState("dismissedAt", Date.now())
}
return (
<Show when={presence.present()}>
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
<div
ref={setRef}
data-component="new-session-tip"
data-visible={tip() !== undefined}
class="group/new-session-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
data-component="provider-tip"
data-visible={visible()}
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
classList={{ "data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true }}
>
<button
type="button"
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
onClick={open}
onClick={openProviders}
>
<span class="truncate">
{language.t(displayed() === "workspace" ? "home.workspaceTip" : "home.providerTip")}
</span>
<span class="truncate">{language.t("home.providerTip")}</span>
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
<Icon name="chevron-down" size="small" class="-rotate-90" />
</span>
</button>
<Tooltip
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/new-session-tip:delay-[250ms] group-hover/new-session-tip:duration-150 group-hover/new-session-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
placement="top"
openDelay={1000}
value={language.t("common.dismiss")}
@@ -220,7 +176,7 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
type="button"
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
aria-label={language.t("common.dismiss")}
onClick={dismiss}
onClick={() => setPersistedState("dismissedAt", Date.now())}
>
<Icon name="xmark-small" />
</button>
@@ -1,5 +1,5 @@
import { debounce } from "@solid-primitives/scheduled"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
@@ -11,8 +11,8 @@ import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import {
isWorkspaceDirectory,
isWorkspaceSelection,
sameDirectory,
workspaceDefaultSelection,
workspaceDirectories,
workspaceSelectionDestination,
} from "@/workspaces/paths"
@@ -56,55 +56,6 @@ export function createNewSessionWorkspaceController(input: {
const current = projectID ? data.project.get(projectID) : undefined
return current ? normalizeProjectInfo(current) : undefined
})
const worktreeSource = createMemo(
() => {
const project = currentProject()
return project ? { projectID: project.id, directory: project.worktree } : undefined
},
undefined,
{ equals: (a, b) => a?.projectID === b?.projectID && a?.directory === b?.directory },
)
const [worktrees, worktreeActions] = createResource(worktreeSource, async (source) => ({
projectID: source.projectID,
items: await serverSDK.api.worktree
.list({ location: { directory: source.directory } })
.catch(() => (currentProject()?.id === source.projectID ? currentProject()?.worktrees : undefined) ?? []),
}))
onCleanup(
serverSDK.event.listen((event) => {
if (event.type === "worktree.updated") void worktreeActions.refetch()
}),
)
// `latest` only skips Suspense once the resource has resolved at least once. Before that it
// behaves like a plain read, which holds the transition that opens the New Session tab until
// the worktree list returns.
const worktreesLoaded = () => worktrees.state === "ready" || worktrees.state === "refreshing"
const worktreeItems = createMemo(() => {
const project = currentProject()
if (!project) return []
if (!worktreesLoaded()) return project.worktrees
const loaded = worktrees.latest
return loaded?.projectID === project.id ? loaded.items : project.worktrees
})
const worktreeDirectories = createMemo(() => {
const project = currentProject()
if (!project) return []
const directories = [
...worktreeItems().map((item) => item.directory),
...project.worktrees.map((item) => item.directory),
...(project.sandboxes ?? []),
]
return directories
.filter((directory) => !sameDirectory(project.worktree, directory))
.filter((directory, index, items) => items.findIndex((item) => sameDirectory(item, directory)) === index)
})
const managedWorktrees = createMemo(() => {
const project = currentProject()
if (!project) return 0
return worktreeItems().filter(
(item) => item.strategy !== undefined && !sameDirectory(project.worktree, item.directory),
).length
})
const visible = createMemo(() =>
resolveNewSessionGit({
projectVcs: currentProject()?.vcs,
@@ -115,11 +66,7 @@ export function createNewSessionWorkspaceController(input: {
const project = currentProject()
const worktree = input.selectedWorktree()
if (!project || !worktree) return
if (isWorkspaceSelection(project, worktree)) return worktree
// A saved choice may only exist in the server inventory. Keep it until the list can confirm it,
// otherwise the selector falls back to Local while loading and a submit would target the wrong directory.
if (!worktreesLoaded()) return worktree
return worktreeDirectories().some((item) => sameDirectory(item, worktree)) ? worktree : undefined
return isWorkspaceSelection(project, worktree) ? worktree : undefined
})
const fallback = createMemo(() => {
const project = currentProject()
@@ -146,19 +93,12 @@ export function createNewSessionWorkspaceController(input: {
.catch(() => ({ directory, search, data: [] })),
)
createEffect(() => {
void Promise.all([
data.location.syncInfo({ directory: sdk().directory }),
data.project.sync(),
data.location.vcs.sync({ directory: sdk().directory }),
]).catch(() => undefined)
})
// Only the selected worktree feeds the branch label. Syncing every worktree in the inventory boots
// each one on the server, which then emits `agent.updated` and makes the client run the full
// catalog fan-out for every directory.
createEffect(() => {
const selection = value()
if (selection === "main" || selection === "create") return
void data.location.vcs.sync({ directory: selection }).catch(() => undefined)
void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch(
() => undefined,
)
const project = currentProject()
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
})
const branch = createMemo(() =>
resolveNewSessionBranch({
@@ -182,10 +122,7 @@ export function createNewSessionWorkspaceController(input: {
workspace: createMemo(() => {
const project = currentProject()
const current = value()
if (current === "create") return true
if (current === "main" || !project) return false
if (isWorkspaceDirectory(project, current) || !worktreesLoaded()) return true
return worktreeDirectories().some((item) => sameDirectory(item, current))
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
}),
reset: () => {
input.setSelectedWorktree(undefined)
@@ -205,8 +142,10 @@ export function createNewSessionWorkspaceController(input: {
},
project: {
root: projectRoot,
workspaces: worktreeDirectories,
managed: managedWorktrees,
workspaces: () => {
const project = currentProject()
return project ? workspaceDirectories(project) : []
},
git: visible,
branches: () => {
const current = data.location.vcs.info({ directory: sdk().directory })?.branch.current
@@ -1,6 +1,5 @@
import { createMemo, createSignal, For, Show } from "solid-js"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/icon"
@@ -26,9 +25,6 @@ export function PromptWorkspaceSelector(props: {
let searchInput: HTMLInputElement | undefined
let branchSearchInput: HTMLInputElement | undefined
let focusSearch = false
const branchTruncation = createTruncatedText()
const focusWorktreeSearch = () =>
requestAnimationFrame(() => requestAnimationFrame(() => searchInput?.focus({ preventScroll: true })))
let pending: { type: "select"; value: string } | { type: "create"; branch: string } | { type: "viewAll" } | undefined
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
const workspaces = createMemo(() => {
@@ -38,8 +34,8 @@ export function PromptWorkspaceSelector(props: {
})
const icon = () => {
if (selected() === "main") return "monitor"
if (selected() === "create") return "plus"
return "outline-worktree"
if (selected() === "create") return "workspace-new"
return "workspace-isolated"
}
const select = (value: string) => {
pending = { type: "select", value }
@@ -70,14 +66,13 @@ export function PromptWorkspaceSelector(props: {
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<Tooltip
appearance={props.onboarding ? "large" : undefined}
placement="top"
openDelay={800}
value={
props.onboarding ? (
<div class="flex flex-col gap-1 text-start">
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
<Icon name="outline-worktree" size="small" class="shrink-0 text-v2-text-text-accent" />
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
<span>{language.t("workspace.onboarding.title")}</span>
</div>
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
@@ -94,10 +89,7 @@ export function PromptWorkspaceSelector(props: {
aria-description={language.t("session.new.workspace.trigger.tooltip")}
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
>
<Icon
name={icon()}
class={`shrink-0 ${selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
/>
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{label()}</span>
<Show when={props.onboarding}>
<span
@@ -117,8 +109,14 @@ export function PromptWorkspaceSelector(props: {
<Tooltip
placement="right"
openDelay={800}
value={language.t("session.new.workspace.local.tooltip")}
contentClass="max-w-[140px]"
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("session.new.workspace.local")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.local.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
@@ -128,22 +126,24 @@ export function PromptWorkspaceSelector(props: {
</Show>
</Menu.Item>
<Menu.Item onSelect={() => select("create")}>
<Icon name="plus" />
<Tooltip
placement="right"
openDelay={800}
value={language.t("session.new.workspace.new.tooltip")}
contentClass="max-w-[140px]"
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</Tooltip>
<Icon name="workspace-new" />
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</Menu.Item>
</Menu.Group>
<Show when={props.workspaces.length > 0}>
<Show
when={props.workspaces.length > 0}
fallback={
<>
<Menu.Separator class="h-[0.5px]" />
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</Menu.Item>
</>
}
>
<Menu.Separator class="h-[0.5px]" />
<Menu.Sub
gutter={0}
@@ -156,11 +156,10 @@ export function PromptWorkspaceSelector(props: {
}
if (!focusSearch || props.workspaces.length < 10) return
focusSearch = false
focusWorktreeSearch()
requestAnimationFrame(() => searchInput?.focus())
}}
>
<Menu.SubTrigger
onClick={focusWorktreeSearch}
onKeyDown={(event) => {
if (
event.key === "ArrowRight" ||
@@ -171,7 +170,7 @@ export function PromptWorkspaceSelector(props: {
focusSearch = true
}}
>
<Icon name="outline-worktree" />
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</span>
@@ -206,7 +205,7 @@ export function PromptWorkspaceSelector(props: {
<For each={workspaces()}>
{(workspace) => (
<Menu.Item onSelect={() => select(workspace)}>
<Icon name="outline-worktree" />
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" />
@@ -214,11 +213,6 @@ export function PromptWorkspaceSelector(props: {
</Menu.Item>
)}
</For>
<Show when={search.workspaces.trim() && workspaces().length === 0}>
<div class="px-3 py-4 text-center text-[13px] font-[440] leading-5 text-v2-text-text-muted">
{language.t("session.new.workspace.search.empty")}
</div>
</Show>
<Menu.Separator class="h-[0.5px]" />
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
@@ -238,7 +232,6 @@ export function PromptWorkspaceSelector(props: {
<Tooltip
placement="top"
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
disabled={!branchTruncation.truncated()}
class="ms-1 min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
@@ -252,7 +245,7 @@ export function PromptWorkspaceSelector(props: {
>
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={branchTruncation.observe} class="min-w-0 truncate">
<span class="min-w-0 truncate">
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
@@ -326,7 +319,6 @@ export function PromptWorkspaceSelector(props: {
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
const language = useLanguage()
const truncation = createTruncatedText()
const label = () => {
if (props.noGit) return language.t("session.new.git.none")
if (!props.branch) return undefined
@@ -346,27 +338,15 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?
<Tooltip
placement="top"
value={value()}
disabled={!truncation.truncated()}
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={truncation.observe} class="min-w-0 truncate">
{value()}
</span>
<span class="min-w-0 truncate">{value()}</span>
</div>
</Tooltip>
)}
</Show>
)
}
function createTruncatedText() {
const [truncated, setTruncated] = createSignal(false)
return {
truncated,
observe: (element: HTMLSpanElement) =>
createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth)),
}
}
+27 -42
View File
@@ -659,7 +659,6 @@ export const dict = {
"home.sessions.group.yesterday": "Yesterday",
"home.sessions.group.older": "Older",
"home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc",
"home.workspaceTip": "Start next session in a new workspace to keep changes isolated",
"session.tab.session": "Session",
"session.tab.files": "Files",
@@ -994,36 +993,37 @@ export const dict = {
"settings.timeline.title": "Timeline",
"settings.timeline.detail": "Timeline detail",
"settings.timeline.description": "Choose how much detail appears in the session timeline.",
"settings.timeline.summary": "{{preset}}:",
"settings.timeline.description": "Choose how much activity appears in the timeline. Messages stay visible.",
"settings.timeline.preset.everything": "Everything",
"settings.timeline.preset.detailed": "Detailed",
"settings.timeline.preset.compact": "Compact",
"settings.timeline.preset.quiet": "Quiet",
"settings.timeline.preset.text-only": "Messages only",
"settings.timeline.preset.text-only": "Text only",
"settings.timeline.description.everything": "Show all activity separately. Expand shell output, edits, and thinking.",
"settings.timeline.description.detailed":
"Expand shell output and edits. Show subagents separately and group other activity.",
"settings.timeline.description.compact": "Group all activity with details collapsed.",
"settings.timeline.description.quiet": "Group edits and subagents. Hide other activity.",
"settings.timeline.description.text-only": "Hide all activity.",
"settings.timeline.description.custom": "Uses advanced settings.",
"Expand shell output and edits. Show subagents separately and group other activity in Used.",
"settings.timeline.description.compact": "Group all activity in Used with details collapsed.",
"settings.timeline.description.quiet": "Group edits and subagents in Used. Hide other activity.",
"settings.timeline.description.text-only": "Hide all activity. Show only messages.",
"settings.timeline.description.custom": "Use your selected placement and details for each activity category.",
"settings.timeline.custom": "Custom",
"settings.timeline.advanced": "Advanced",
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
"settings.timeline.group": "Group",
"settings.timeline.collapse": "Collapse",
"settings.timeline.visibility.show": "Show",
"settings.timeline.visibility.hide": "Hide",
"settings.timeline.visibility.label": "{{activity}} visibility",
"settings.timeline.grouped.label": "{{activity}} grouped",
"settings.timeline.collapsed.label": "{{activity}} collapsed",
"settings.timeline.advanced.explainer": "Grouped activity goes into Used. Details applies after opening the group.",
"settings.timeline.activity": "Activity",
"settings.timeline.category.shell": "Shell",
"settings.timeline.category.edit": "Edits",
"settings.timeline.category.thinking": "Thinking",
"settings.timeline.category.subagents": "Subagents",
"settings.timeline.category.notices": "Notices",
"settings.timeline.category.tools": "Other tools",
"settings.timeline.placement.title": "Placement",
"settings.timeline.placement.separate": "Separate",
"settings.timeline.placement.grouped": "Grouped",
"settings.timeline.placement.hidden": "Hidden",
"settings.timeline.expansion.title": "Details",
"settings.timeline.expansion.collapsed": "Collapsed",
"settings.timeline.expansion.expanded": "Expanded",
"settings.general.row.language.title": "Language",
"settings.general.row.language.description": "Change the display language for OpenCode",
@@ -1250,43 +1250,30 @@ export const dict = {
"workspace.new": "New worktree",
"common.viewAll": "View all",
"session.new.workspace.local.tooltip": "Uses projects current checkout",
"session.new.workspace.new.tooltip": "Creates isolated copy from current checkout",
"session.new.workspace.local.tooltip": "Use current checkout",
"session.new.workspace.new.tooltip": "Create isolated checkout",
"session.new.workspace.fromBranch": "from {{branch}}",
"session.new.workspace.createFrom": "Create from branch",
"session.new.workspace.branch.search.placeholder": "Search branches",
"session.new.workspace.trigger.tooltip": "Select where to run session",
"session.new.workspace.search.placeholder": "Search worktrees",
"session.new.workspace.search.empty": "No matching worktrees",
"settings.tab.workspaces": "Worktrees",
"settings.workspaces.description": "Review worktrees and manage disk usage",
"settings.workspaces.filter.all": "All projects",
"settings.workspaces.empty": "No worktrees yet",
"settings.workspaces.empty.description": "Worktrees created in OpenCode will appear here",
"settings.workspaces.empty": "No worktrees",
"settings.workspaces.count.one": "{{count}} worktree",
"settings.workspaces.count.other": "{{count}} worktrees",
"settings.workspaces.sessions.one": "{{count}} session in {{project}}",
"settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
"settings.workspaces.sessions.filtered.one": "{{count}} session",
"settings.workspaces.sessions.filtered.other": "{{count}} sessions",
"settings.workspaces.lastActiveSession": "Last active session",
"settings.workspaces.deleteAll": "Delete all worktrees",
"settings.workspaces.deleteAll.confirm.one": "This will permanently delete {{count}} selected worktree.",
"settings.workspaces.deleteAll.confirm.other": "This will permanently delete {{count}} selected worktrees.",
"settings.workspaces.deleteWithoutSessions": "Delete worktrees without sessions",
"settings.workspaces.deleteWithoutSessions.confirm.one":
"This will permanently delete {{count}} worktree without linked sessions.",
"settings.workspaces.deleteWithoutSessions.confirm.other":
"This will permanently delete {{count}} worktrees without linked sessions.",
"settings.workspaces.deleteWithoutSessions.warning":
"Worktrees with unmerged changes or active locations will be kept.",
"settings.workspaces.delete.button": "Delete worktrees",
"settings.workspaces.delete.warning": "This permanently deletes the worktree and its branch.",
"settings.workspaces.deleteAll.confirm": "Delete all {{count}} worktrees?",
"settings.workspaces.delete.warning":
"The worktree directory and branch will be permanently removed, including any unmerged changes shown below.",
"settings.workspaces.deleteAll.warning":
"Worktrees with unmerged changes, linked sessions, or active locations will be kept.",
"The {{count}} selected worktrees in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
"settings.workspaces.delete.blocked.active": "The active worktree cannot be deleted.",
"settings.workspaces.delete.blocked.linked":
"Linked sessions will remain, but their working directory will no longer exist.",
"Linked sessions will remain, but their working directory will be permanently removed.",
"settings.workspaces.default.title": "Default environment",
"settings.workspaces.default.description": "Choose where new sessions start",
"settings.workspaces.default.lastUsed": "Last used per project",
@@ -1297,10 +1284,9 @@ export const dict = {
"workspace.move.failed": "Failed to move session",
"workspace.lifecycle.creating": "Creating worktree",
"workspace.lifecycle.created": "Worktree created",
"workspace.lifecycle.deleting": "Deleting…",
"workspace.lifecycle.starting": "Starting session",
"workspace.onboarding.title": "Isolate sessions with worktrees",
"workspace.onboarding.description": "Each gets its own checkout",
"workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
"workspace.lifecycle.moving": "Moving to worktree",
"workspace.lifecycle.set": "Worktree set",
"session.summary.title": "Session details",
@@ -1319,10 +1305,9 @@ export const dict = {
"workspace.status.checking": "Checking for unmerged changes…",
"workspace.status.error": "Unable to verify git status.",
"workspace.status.clean": "No unmerged changes detected.",
"workspace.status.dirty": "Unmerged changes will be lost.",
"workspace.status.dirty": "Unmerged changes detected in this worktree.",
"workspace.delete.title": "Delete worktree",
"workspace.delete.confirm": "Delete {{name}}”?",
"workspace.delete.location": "Location",
"workspace.delete.confirm": 'Delete worktree "{{name}}"?',
"workspace.delete.button": "Delete worktree",
"workspace.reset.title": "Reset worktree",
"workspace.reset.confirm": 'Reset worktree "{{name}}"?',
@@ -49,36 +49,6 @@ migration rules remain explicit in their schemas.
invalid entries individually. Valid entries still pass through their codecs.
- Recovery is not a substitute for an explicit historical shape transformation.
## Writes
The setter returned by `persisted()` only marks the store dirty (`persist.ts`). The store is
serialized once per save window (`persistSaveDelay`), on owner cleanup, and when the page
hides, and the write is skipped when the serialized form did not change. Reactive observers
therefore see every mutation immediately and a burst of setter calls costs one encode. Call
`flushPersisted()` when a test or a shutdown path needs the write to have happened; the
desktop platform calls it before flushing its namespaces on shutdown. A real unsaved local
change wins over a value arriving from another window, and over a stored value that finishes
loading after the user already edited. A remote value that arrives while the store is dirty
is held until the save runs; if the local setter calls turned out not to change the
serialized form, the remote value is adopted instead of being lost.
## Namespaces
On desktop, `platform.storage(name)` returns a `NamespaceStorage` (`namespace.ts`): the
in-memory truth for one storage namespace, modelled on VS Code's `Storage` class. The
namespace is loaded from the host once, reads are Map lookups from then on, and writes
update the cache immediately while being batched into one host round trip per flush
window (`namespaceFlushDelay`). `flush()` hands the batch to the driver synchronously, so a
flush on page hide is on the wire before the page goes away; the desktop platform flushes
every namespace before the IPC runtime is disposed and whenever the window is hidden. Each
local write carries a sequence number that is kept until the host accepts that exact write;
until then neither the initial load, a change from another window (`accept`), nor the retry
of an older failed batch can replace the key. The host also stamps every update with a
monotonic revision, returned in the ack and carried by change events and loads, so an event
that reaches a window after a newer ack or load for the same key is recognised as stale and
dropped; an event held back during an in-flight write is applied after the ack when the host
ordered it later.
## Migrations
Describe shipped representations with schemas and transform their typed values
@@ -108,28 +78,3 @@ Storage-key relocation (`previousKey`, workspace aliases, draft storage moves)
remains separate from schema migration. Draft blob externalization and hydration
also remain in the storage adapter: composer codecs receive hydrated references,
not raw ID-only blob documents.
## Large draft content
Draft documents never carry large text inline. Any string of `draftTextThreshold`
characters or more is split into `draftTextChunk`-sized content-addressed blobs and
stored as `{ blob: { kind: "text", ids: [...] } }`; reads join the chunks again. A
content-keyed cache means unchanged chunks are not hashed or sent on later saves, so
typing after a large paste uploads one chunk per save rather than the paste. Chunk
boundaries never split a surrogate pair, and a failed upload is evicted so the next save
retries it.
Blob collection is made safe by validation on write, not by timing. A strict document write
is refused while it references blob ids the store does not hold, so the previous document
stays visible; the renderer uploads the missing bytes again from the chunk text or the image
`Blob` it still holds, renames the references to the ids the uploads returned (content hashes
normally, fresh ids on a store without WebCrypto), and publishes. This covers a chunk another
tab collected, and an image the composer kept in its history long after its blob was
collected. The desktop host additionally refreshes `touched_at` for every blob a written
document references and collects only blobs unreferenced and untouched for `blobGrace`, so
repairs stay rare.
`persisted()` hands the draft store the encoded document (`setDocument`) rather than
a serialized string, so the store does not re-parse the full document to externalize
it. Both blob collectors (desktop SQL, browser IndexedDB) keep chunk ids alive. This
follows VS Code's rule that editor content lives in per-resource backups, not in the
state database.
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { WorkspaceOnboardingSchema, ProviderTipSchema, WorkspaceTipSchema } from "@/new-session/view"
import { WorkspaceOnboardingSchema, ProviderTipSchema } from "@/new-session/view"
import { ModelSelectionSchema } from "@/providers/models/selection"
import { Persistence } from "@/runtime/persistence/schema"
import { FileViewsSchema } from "@/workspaces/files/view-cache"
@@ -12,7 +12,6 @@ describe("persisted consumer schemas", () => {
test("onboarding and provider tip retain defaults and validate stored values", () => {
const onboarding = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceOnboardingSchema, { used: false }))
const tip = Schema.decodeUnknownSync(Persistence.withInitial(ProviderTipSchema, { dismissedAt: 0 }))
const workspaceTip = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }))
expect(onboarding({})).toEqual({ used: false })
expect(onboarding({ used: "true" })).toEqual({ used: false })
expect(onboarding({ used: true })).toEqual({ used: true })
@@ -20,7 +19,6 @@ describe("persisted consumer schemas", () => {
expect(tip({ dismissedAt: "yesterday" })).toEqual({ dismissedAt: 0 })
expect(tip({ dismissedAt: Infinity })).toEqual({ dismissedAt: 0 })
expect(tip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
expect(workspaceTip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
})
test("collapse records recover malformed entries without losing valid siblings", () => {
@@ -1,232 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createDraftStore, draftTextChunk, draftTextThreshold } from "./drafts"
function memoryDriver() {
const documents = new Map<string, string>()
const blobs = new Map<string, Blob>()
let puts = 0
return {
documents,
blobs,
puts: () => puts,
driver: {
get: async (key: string) => documents.get(key) ?? null,
// Like the real stores: report referenced blobs that are not held; a strict write with any
// missing is refused.
set: async (key: string, value: string, strict: boolean) => {
const ids = new Set<string>()
JSON.parse(value, (_key, item) => {
if (item?.blob && typeof item.blob.id === "string") ids.add(item.blob.id)
if (item?.blob && Array.isArray(item.blob.ids)) item.blob.ids.forEach((id: unknown) => ids.add(String(id)))
return item
})
const missing = [...ids].filter((id) => !blobs.has(id))
if (!strict || missing.length === 0) documents.set(key, value)
return missing
},
remove: async (key: string) => void documents.delete(key),
putBlob: async (blob: Blob) => {
puts++
const id = `blob-${await blob.text().then((text) => Bun.hash(text).toString(16))}`
blobs.set(id, blob)
return id
},
getBlob: async (id: string) => blobs.get(id) ?? null,
},
}
}
const large = "x".repeat(draftTextThreshold)
const paste = Array.from({ length: 3 * draftTextChunk }, (_, i) => String.fromCharCode(97 + (i % 26))).join("")
describe("draft store text externalization", () => {
test("large strings become chunk lists and small ones stay inline", async () => {
const memory = memoryDriver()
const store = createDraftStore(memory.driver)
await store.setDocument("doc", {
prompt: [
{ type: "text", content: paste },
{ type: "text", content: "hi" },
],
})
const stored = JSON.parse(memory.documents.get("doc")!)
expect(stored.prompt[0].content.blob.kind).toBe("text")
expect(stored.prompt[0].content.blob.ids).toHaveLength(3)
expect(stored.prompt[1].content).toBe("hi")
expect(memory.documents.get("doc")!.length).toBeLessThan(400)
const chunks = await Promise.all(
stored.prompt[0].content.blob.ids.map((id: string) => memory.blobs.get(id)!.text()),
)
expect(chunks.join("")).toBe(paste)
})
test("appending to a large string re-uploads only the final chunk", async () => {
const memory = memoryDriver()
const store = createDraftStore(memory.driver)
await store.setDocument("doc", { prompt: [{ type: "text", content: paste }] })
expect(memory.puts()).toBe(3)
await store.setDocument("doc", { prompt: [{ type: "text", content: `${paste}!` }] })
expect(memory.puts()).toBe(4)
await store.setDocument("doc", { prompt: [{ type: "text", content: `${paste}!` }], cursor: 1 })
expect(memory.puts()).toBe(4)
})
test("reads join the chunks again and reuse cached content", async () => {
const memory = memoryDriver()
const store = createDraftStore(memory.driver)
await store.setDocument("doc", { prompt: [{ type: "text", content: paste }] })
const fresh = createDraftStore(memory.driver)
expect(JSON.parse((await fresh.getItem("doc"))!)).toEqual({ prompt: [{ type: "text", content: paste }] })
memory.blobs.clear()
expect(JSON.parse((await fresh.getItem("doc"))!)).toEqual({ prompt: [{ type: "text", content: paste }] })
})
test("a missing chunk decodes to empty text instead of failing the document", async () => {
const memory = memoryDriver()
memory.documents.set(
"doc",
JSON.stringify({ prompt: [{ type: "text", content: { blob: { kind: "text", ids: ["gone"] } } }] }),
)
const store = createDraftStore(memory.driver)
expect(JSON.parse((await store.getItem("doc"))!)).toEqual({ prompt: [{ type: "text", content: "" }] })
})
test("a cached chunk id the store no longer holds is uploaded again on the next save", async () => {
const memory = memoryDriver()
const store = createDraftStore(memory.driver)
await store.setDocument("doc", { prompt: [{ type: "text", content: large }] })
const [id] = JSON.parse(memory.documents.get("doc")!).prompt[0].content.blob.ids
await store.setDocument("doc", { prompt: [{ type: "text", content: `${large}!` }] })
// Another tab collected the chunk for `large` while this tab still caches its id.
memory.blobs.clear()
// Undo republishes the cached id; the write reports it missing and the chunk is uploaded again.
await store.setDocument("doc", { prompt: [{ type: "text", content: large }] })
expect(memory.puts()).toBe(3)
const fresh = createDraftStore(memory.driver)
expect(JSON.parse((await fresh.getItem("doc"))!).prompt[0].content).toBe(large)
expect(memory.blobs.has(id)).toBe(true)
})
test("an image reference whose blob was collected is restored from its object url", async () => {
const memory = memoryDriver()
const store = createDraftStore(memory.driver)
const image = await store.putBlob(new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }))
// The composer kept this reference (for example in its history) while the store collected the bytes.
memory.blobs.clear()
await store.setDocument("doc", { prompt: [{ type: "image", blob: { id: image.id, url: image.url } }] })
expect(memory.blobs.has(image.id)).toBe(true)
expect(new Uint8Array(await memory.blobs.get(image.id)!.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3]))
})
test("the previous document stays visible until missing blobs are restored", async () => {
const memory = memoryDriver()
const store = createDraftStore(memory.driver)
await store.setDocument("doc", { prompt: [{ type: "text", content: large }] })
await store.setDocument("doc", { prompt: [{ type: "text", content: `${large}!` }] })
const before = memory.documents.get("doc")
memory.blobs.clear()
// Hold the repair upload: while it is pending, another reader must still see the old document.
const gate = Promise.withResolvers<void>()
const putBlob = memory.driver.putBlob
memory.driver.putBlob = async (blob) => {
await gate.promise
return putBlob(blob)
}
const saving = store.setDocument("doc", { prompt: [{ type: "text", content: large }] })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(memory.documents.get("doc")).toBe(before)
gate.resolve()
await saving
expect(JSON.parse(memory.documents.get("doc")!).prompt[0].content.blob.ids).toHaveLength(1)
const fresh = createDraftStore(memory.driver)
expect(JSON.parse((await fresh.getItem("doc"))!).prompt[0].content).toBe(large)
})
test("references are renamed when a restored blob comes back under a different id", async () => {
const memory = memoryDriver()
// A store without WebCrypto assigns a fresh id to every upload.
let counter = 0
memory.driver.putBlob = async (blob) => {
const id = `random-${counter++}`
memory.blobs.set(id, blob)
return id
}
const store = createDraftStore(memory.driver)
const image = await store.putBlob(new Blob([new Uint8Array([7])], { type: "image/png" }))
await store.setDocument("doc", {
prompt: [
{ type: "text", content: paste },
{ type: "image", blob: image },
],
})
const original = JSON.parse(memory.documents.get("doc")!)
memory.blobs.clear()
await store.setDocument("doc", {
prompt: [
{ type: "text", content: paste },
{ type: "image", blob: image },
],
})
const restored = JSON.parse(memory.documents.get("doc")!)
expect(restored.prompt[0].content.blob.ids).not.toEqual(original.prompt[0].content.blob.ids)
expect(restored.prompt[1].blob.id).not.toBe(original.prompt[1].blob.id)
for (const id of [...restored.prompt[0].content.blob.ids, restored.prompt[1].blob.id])
expect(memory.blobs.has(id)).toBe(true)
const fresh = createDraftStore(memory.driver)
const read = JSON.parse((await fresh.getItem("doc"))!)
expect(read.prompt[0].content).toBe(paste)
expect(read.prompt[1].blob.id).toBe(restored.prompt[1].blob.id)
// The renamed ids are what later encodes publish, so saves of the still-live references (the
// composer keeps the original image id) upload nothing and keep one stable image id.
const puts = counter
for (const cursor of [1, 2, 3]) {
await store.setDocument("doc", {
prompt: [
{ type: "text", content: paste },
{ type: "image", blob: image },
],
cursor,
})
expect(JSON.parse(memory.documents.get("doc")!).prompt[1].blob.id).toBe(restored.prompt[1].blob.id)
}
expect(counter).toBe(puts)
})
test("chunk boundaries never split a surrogate pair", async () => {
const memory = memoryDriver()
const store = createDraftStore(memory.driver)
const text = "x".repeat(draftTextChunk - 1) + "😀tail"
await store.setDocument("doc", { prompt: [{ type: "text", content: text }] })
const stored = JSON.parse(memory.documents.get("doc")!)
const bytes = await Promise.all(stored.prompt[0].content.blob.ids.map((id: string) => memory.blobs.get(id)!.text()))
expect(bytes.join("")).toBe(text)
expect(bytes[0]!.length).toBe(draftTextChunk + 1)
const fresh = createDraftStore(memory.driver)
expect(JSON.parse((await fresh.getItem("doc"))!).prompt[0].content).toBe(text)
})
test("a failed chunk upload is retried on the next save instead of being reused", async () => {
const memory = memoryDriver()
let failNext = true
const putBlob = memory.driver.putBlob
memory.driver.putBlob = async (blob) => {
if (failNext) {
failNext = false
throw new Error("offline")
}
return putBlob(blob)
}
const store = createDraftStore(memory.driver)
await expect(store.setDocument("doc", { prompt: [{ type: "text", content: paste }] })).rejects.toThrow("offline")
await store.setDocument("doc", { prompt: [{ type: "text", content: `${paste}!` }] })
const fresh = createDraftStore(memory.driver)
expect(JSON.parse((await fresh.getItem("doc"))!).prompt[0].content).toBe(`${paste}!`)
})
test("setItem still accepts a serialized document", async () => {
const memory = memoryDriver()
const store = createDraftStore(memory.driver)
await store.setItem("doc", JSON.stringify({ prompt: [{ type: "text", content: large }] }))
expect(JSON.parse(memory.documents.get("doc")!).prompt[0].content.blob.ids).toHaveLength(1)
})
})
+19 -203
View File
@@ -5,43 +5,20 @@ export type BlobReference = { id: string; url: string }
type Driver = {
get(key: string): Promise<string | null>
/**
* Store the document and report every blob id it references that the store does not hold. A
* strict write is refused (nothing stored) when any are missing, so a document is never visible
* with dangling references while its blobs are being restored.
*/
set(key: string, value: string, strict: boolean): Promise<readonly string[]>
set(key: string, value: string): Promise<void>
remove(key: string): Promise<void>
putBlob(blob: Blob): Promise<string>
getBlob(id: string): Promise<Blob | null>
}
export type DraftStore = AsyncStorage & {
putBlob(blob: Blob): Promise<BlobReference>
/** Persist an already-encoded document without re-parsing its serialized form. */
setDocument(key: string, document: unknown): Promise<void>
}
// Strings at least this long leave the document as fixed-size content-addressed chunks. Typing
// after a large paste changes only the final chunk, so a save uploads one chunk, not the paste.
export const draftTextThreshold = 16 * 1024
export const draftTextChunk = 64 * 1024
const textCacheLimit = 64
export type DraftStore = AsyncStorage & { putBlob(blob: Blob): Promise<BlobReference> }
const urls = new Map<string, string>()
// The object URL already pins the Blob for the page's lifetime; keeping the Blob itself lets a
// collected image be uploaded again without fetching the URL.
const held = new Map<string, Blob>()
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
// ones); live references still carry the original.
const aliases = new Map<string, string>()
function blobUrl(id: string, blob: Blob) {
const existing = urls.get(id)
if (existing) return existing
const url = URL.createObjectURL(blob)
urls.set(id, url)
held.set(id, blob)
return url
}
@@ -79,82 +56,25 @@ export function createDraftStore(driver: Driver): DraftStore {
const id = await driver.putBlob(blob)
return { id, url: blobUrl(id, blob) }
}
// Keyed by chunk content so unchanged chunks are never hashed or sent again while the draft is
// edited. Bounded because each entry pins up to draftTextChunk characters. A hit is safe even if
// the store has since collected the blob: the write reports it missing and it is uploaded again.
const chunkIds = new Map<string, Promise<string>>()
const chunks = new Map<string, string>()
const remember = <V>(cache: Map<string, V>, key: string, value: V) => {
cache.set(key, value)
if (cache.size > textCacheLimit) cache.delete(cache.keys().next().value!)
return value
}
const upload = (chunk: string) => {
const id = driver.putBlob(new Blob([chunk])).then(
(id) => {
remember(chunks, id, chunk)
return id
},
(error: unknown) => {
// A failed upload must not be reused as the answer for this content on later saves.
if (chunkIds.get(chunk) === id) chunkIds.delete(chunk)
throw error
},
)
return remember(chunkIds, chunk, id)
}
const externalize = (text: string) => Promise.all(split(text).map((chunk) => chunkIds.get(chunk) ?? upload(chunk)))
const loadChunk = async (id: string) => {
const cached = chunks.get(id)
if (cached !== undefined) return cached
const blob = await driver.getBlob(id)
// A missing chunk loses that text but keeps the rest of the document decodable.
return remember(chunks, id, blob ? await blob.text() : "")
}
// `sources` collects, for every blob id the encoded document references, a way to produce its
// bytes again: the chunk text itself, or the image Blob (or object URL) the reference carries.
type Sources = Map<string, { blob: () => Promise<Blob>; chunk?: string }>
const encode = async (value: unknown, sources: Sources): Promise<unknown> => {
if (typeof value === "string" && value.length >= draftTextThreshold) {
const pieces = split(value)
const ids = await externalize(value)
ids.forEach((id, index) =>
sources.set(id, { blob: async () => new Blob([pieces[index]!]), chunk: pieces[index] }),
)
return { blob: { kind: "text", ids } }
}
if (Array.isArray(value)) return Promise.all(value.map((entry) => encode(entry, sources)))
const encode = async (value: unknown): Promise<unknown> => {
if (Array.isArray(value)) return Promise.all(value.map(encode))
if (!value || typeof value !== "object") return value
const item = value as Record<string, unknown>
if (item.type === "image" && typeof item.dataUrl === "string") {
const blob = await fetch(item.dataUrl).then((response) => response.blob())
const { dataUrl: _, ...rest } = item
const id = await driver.putBlob(blob)
sources.set(id, { blob: async () => blob })
return { ...rest, blob: { id } }
return { ...rest, blob: { id: await driver.putBlob(blob) } }
}
if ("blob" in item && item.blob && typeof item.blob === "object") {
const blob = item.blob as Record<string, unknown>
if (blob.kind === "text") return item
if (typeof blob.id === "string" && blob.id.startsWith("data:")) {
const data = await fetch(blob.id).then((response) => response.blob())
const id = await driver.putBlob(data)
sources.set(id, { blob: async () => data })
return { ...item, blob: { id } }
}
if (typeof blob.id === "string") {
// A live reference keeps the id it was created with; publish the id its bytes now live under.
const id = aliases.get(blob.id) ?? blob.id
const kept = held.get(id)
const url = typeof blob.url === "string" ? blob.url : urls.get(id)
if (kept) sources.set(id, { blob: async () => kept })
else if (url) sources.set(id, { blob: () => fetch(url).then((response) => response.blob()) })
return { ...item, blob: { id } }
return { ...item, blob: { id: await driver.putBlob(data) } }
}
return { ...item, blob: { id: blob.id } }
}
return Object.fromEntries(
await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await encode(entry, sources)])),
await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await encode(entry)])),
)
}
const decode = async (value: unknown): Promise<unknown> => {
@@ -163,9 +83,6 @@ export function createDraftStore(driver: Driver): DraftStore {
const item = value as Record<string, unknown>
if (item.blob && typeof item.blob === "object") {
const ref = item.blob as Record<string, unknown>
if (ref.kind === "text" && Array.isArray(ref.ids)) {
return (await Promise.all(ref.ids.map((id) => loadChunk(String(id))))).join("")
}
if (typeof ref.id === "string") {
const url = await loadBlobUrl(ref.id)
if (url) return { ...item, blob: { id: ref.id, url } }
@@ -175,65 +92,6 @@ export function createDraftStore(driver: Driver): DraftStore {
await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await decode(entry)])),
)
}
// Upload the bytes behind `ids` again and return the ids they were stored under. Ids are
// usually content hashes and come back unchanged, but a store without WebCrypto assigns fresh
// ones, so callers must rename references rather than assume.
const restore = async (ids: readonly string[], sources: Sources) => {
const renamed = new Map<string, string>()
await Promise.all(
ids.map(async (id) => {
const source = sources.get(id)
if (!source) return
const blob = await source.blob()
const next = await driver.putBlob(blob)
renamed.set(id, next)
if (source.chunk !== undefined) {
remember(chunkIds, source.chunk, Promise.resolve(next))
remember(chunks, next, source.chunk)
return
}
held.set(next, blob)
blobUrl(next, blob)
if (next === id) return
// Later encodes of the still-live reference resolve straight to the new id. Re-point any
// earlier alias chain so lookups stay one step.
for (const [from, to] of aliases) if (to === id) aliases.set(from, next)
aliases.set(id, next)
}),
)
return renamed
}
const rename = (value: unknown, renamed: Map<string, string>): unknown => {
if (Array.isArray(value)) return value.map((entry) => rename(entry, renamed))
if (!value || typeof value !== "object") return value
const item = value as Record<string, unknown>
if (item.blob && typeof item.blob === "object") {
const ref = item.blob as Record<string, unknown>
if (Array.isArray(ref.ids))
return { ...item, blob: { ...ref, ids: ref.ids.map((id) => renamed.get(String(id)) ?? id) } }
if (typeof ref.id === "string") return { ...item, blob: { ...ref, id: renamed.get(ref.id) ?? ref.id } }
}
return Object.fromEntries(Object.entries(item).map(([key, entry]) => [key, rename(entry, renamed)]))
}
const setDocument = async (key: string, document: unknown) => {
const version = (versions.get(key) ?? 0) + 1
versions.set(key, version)
const sources: Sources = new Map()
const encoded = await encode(document, sources)
if (versions.get(key) !== version) return
// The store refuses the write while any referenced blob is missing, so the previous document
// stays visible until the bytes are back. Covers a blob collected while a cache, another tab,
// or the composer's history still held its id.
const missing = await driver.set(key, JSON.stringify(encoded), true)
if (missing.length === 0) return
const renamed = await restore(missing, sources)
if (versions.get(key) !== version) return
const unrestored = missing.filter((id) => !renamed.has(id))
if (unrestored.length)
console.error(`[persistence] draft ${key} references blobs with no bytes to restore`, unrestored)
// Anything still missing has no bytes anywhere; the owning codec drops such references on read.
await driver.set(key, JSON.stringify(rename(encoded, renamed)), false)
}
return {
getItem: async (key) => {
const value = await driver.get(key)
@@ -243,8 +101,12 @@ export function createDraftStore(driver: Driver): DraftStore {
if (Option.isNone(parsed)) return value
return JSON.stringify(await decode(parsed.value))
},
setItem: (key, value) => setDocument(key, JSON.parse(value)),
setDocument,
setItem: async (key, value) => {
const version = (versions.get(key) ?? 0) + 1
versions.set(key, version)
const encoded = JSON.stringify(await encode(JSON.parse(value)))
if (versions.get(key) === version) await driver.set(key, encoded)
},
removeItem: async (key) => {
versions.set(key, (versions.get(key) ?? 0) + 1)
await driver.remove(key)
@@ -253,20 +115,6 @@ export function createDraftStore(driver: Driver): DraftStore {
}
}
// Fixed-size pieces, except that a piece never ends between the two halves of a surrogate pair:
// each piece becomes its own Blob, and an unpaired surrogate would be encoded as U+FFFD.
function split(text: string) {
const pieces: string[] = []
for (let start = 0; start < text.length; ) {
const end = Math.min(start + draftTextChunk, text.length)
const code = text.charCodeAt(end - 1)
const stop = end < text.length && code >= 0xd800 && code <= 0xdbff ? end + 1 : end
pieces.push(text.slice(start, stop))
start = stop
}
return pieces
}
export function createBrowserDraftStore(): DraftStore {
const request = indexedDB.open("opencode-drafts", 1)
request.addEventListener("upgradeneeded", () => {
@@ -279,7 +127,11 @@ export function createBrowserDraftStore(): DraftStore {
const transaction = database.transaction(["documents", "blobs"], "readwrite")
const documents = transaction.objectStore("documents").getAll()
documents.addEventListener("success", () => {
const used = referenced(`[${documents.result.join(",")}]`)
const used = new Set<string>()
JSON.parse(`[${documents.result.join(",")}]`, (_key, item) => {
if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id)
return item
})
const store = transaction.objectStore("blobs")
const blobs = store.openKeyCursor()
blobs.addEventListener("success", () => {
@@ -312,32 +164,7 @@ export function createBrowserDraftStore(): DraftStore {
}
return createDraftStore({
get: async (key) => ((await get("documents", key)) as string | undefined) ?? null,
set: async (key, value, strict) => {
// One readwrite transaction over both stores: IndexedDB serialises overlapping readwrite
// transactions in creation order, so a later save or removal cannot commit between the
// reference check and this write. The put is issued from the last lookup's callback so the
// transaction is never left without a pending request.
const ids = [...referenced(value)]
const transaction = (await db).transaction(["blobs", "documents"], "readwrite")
const missing: string[] = []
const publish = () => {
if (!strict || missing.length === 0) transaction.objectStore("documents").put(value, key)
}
let remaining = ids.length
if (remaining === 0) publish()
for (const id of ids) {
const lookup = transaction.objectStore("blobs").getKey(id)
lookup.addEventListener("success", () => {
if (lookup.result === undefined) missing.push(id)
if (--remaining === 0) publish()
})
}
return new Promise<string[]>((resolve, reject) => {
transaction.addEventListener("complete", () => resolve(missing))
transaction.addEventListener("error", () => reject(transaction.error))
transaction.addEventListener("abort", () => reject(transaction.error))
})
},
set: (key, value) => write("documents", key, value),
remove: (key) => write("documents", key),
putBlob: async (blob) => {
const id = await blobID(blob)
@@ -348,17 +175,6 @@ export function createBrowserDraftStore(): DraftStore {
})
}
// Every blob id a serialized document (or array of documents) references.
function referenced(json: string) {
const ids = new Set<string>()
JSON.parse(json, (_key, item) => {
if (item?.blob && typeof item.blob.id === "string") ids.add(item.blob.id)
if (item?.blob && Array.isArray(item.blob.ids)) item.blob.ids.forEach((id: unknown) => ids.add(String(id)))
return item
})
return ids
}
export async function blobDataUrl(blob: BlobReference, mime: string) {
const data = await fetch(blob.url).then((response) => response.blob())
return new Promise<string>((resolve, reject) => {
@@ -1,247 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createNamespaceStorage, type NamespaceDriver, type NamespaceStorage } from "./namespace"
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
type Call = { kind: string; name: string; insert?: Record<string, string>; remove?: string[] }
// A host with a monotonic revision. Each update is acked to its caller and recorded as an event
// that the test delivers to other windows whenever it chooses, like the real event stream.
function host(initial: Record<string, Record<string, string>> = {}) {
const data = new Map(Object.entries(initial).map(([name, items]) => [name, new Map(Object.entries(items))]))
const calls: Call[] = []
const events: { name: string; insert: Record<string, string>; remove: string[]; revision: number }[] = []
let revision = 0
let fail: (insert: Record<string, string>) => boolean = () => false
let gate: Promise<void> | undefined
const driver: NamespaceDriver = {
items: async (name) => {
calls.push({ kind: "items", name })
return { items: Object.fromEntries(data.get(name) ?? []), revision }
},
update: async (name, insert, remove) => {
calls.push({ kind: "update", name, insert, remove })
await gate
if (fail(insert)) throw new Error("disk full")
const items = data.get(name) ?? new Map()
for (const [key, value] of Object.entries(insert)) items.set(key, value)
for (const key of remove) items.delete(key)
data.set(name, items)
events.push({ name, insert, remove, revision: ++revision })
return revision
},
clear: async (name) => {
calls.push({ kind: "clear", name })
data.delete(name)
},
}
return {
driver,
data,
calls,
events,
updates: () => calls.filter((call) => call.kind === "update"),
setFail: (value: boolean | ((insert: Record<string, string>) => boolean)) =>
(fail = typeof value === "boolean" ? () => value : value),
setGate: (value: Promise<void> | undefined) => (gate = value),
deliver: (target: NamespaceStorage, index: number) => {
const event = events[index]!
target.accept(event.insert, event.remove, event.revision)
},
}
}
describe("namespace storage", () => {
test("loads the namespace once and serves reads from memory", async () => {
const h = host({ w: { tabs: "[]", recent: "{}" } })
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
expect(await storage.getItem("tabs")).toBe("[]")
expect(await storage.getItem("recent")).toBe("{}")
expect(await storage.getItem("missing")).toBeNull()
expect(await storage.getLength()).toBe(2)
expect(h.calls.filter((call) => call.kind === "items")).toHaveLength(1)
})
test("reads its own writes immediately and coalesces them into one update", async () => {
const h = host()
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
void storage.setItem("tabs", "[1]")
void storage.setItem("recent", "{}")
void storage.setItem("tabs", "[1,2]")
void storage.removeItem("recent")
expect(await storage.getItem("tabs")).toBe("[1,2]")
expect(await storage.getItem("recent")).toBeNull()
expect(h.updates()).toHaveLength(0)
await wait(30)
expect(h.updates()).toEqual([{ kind: "update", name: "w", insert: { tabs: "[1,2]" }, remove: ["recent"] }])
})
test("writes made while loading win over the loaded snapshot", async () => {
const h = host({ w: { tabs: "old" } })
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
const read = storage.getItem("tabs")
void storage.setItem("tabs", "new")
expect(await read).toBe("new")
})
test("flush writes now and resolves after the driver accepted the batch", async () => {
const h = host()
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
void storage.setItem("tabs", "[1]")
await storage.flush()
expect(h.data.get("w")?.get("tabs")).toBe("[1]")
await storage.flush()
expect(h.updates()).toHaveLength(1)
})
test("a failed update keeps unsuperseded changes queued for the next flush", async () => {
const h = host()
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
h.setFail(true)
void storage.setItem("tabs", "[1]")
void storage.setItem("recent", "{}")
await storage.flush()
expect(h.data.get("w")).toBeUndefined()
h.setFail(false)
void storage.setItem("tabs", "[2]")
await storage.flush()
expect(Object.fromEntries(h.data.get("w")!)).toEqual({ tabs: "[2]", recent: "{}" })
})
test("a batch is handed to the driver synchronously, not behind an earlier reply", async () => {
const h = host()
const first = Promise.withResolvers<void>()
h.setGate(first.promise)
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
void storage.setItem("tabs", "[1]")
void storage.flush()
void storage.setItem("tabs", "[2]")
void storage.flush()
// Both batches reached the driver while the first reply is still outstanding.
expect(h.updates().map((call) => call.insert)).toEqual([{ tabs: "[1]" }, { tabs: "[2]" }])
first.resolve()
await storage.flush()
})
test("a pending load or an external change cannot overwrite a value that is in flight", async () => {
const loaded = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
const accepted = Promise.withResolvers<number>()
const driver: NamespaceDriver = {
items: () => loaded.promise,
update: () => accepted.promise,
clear: async () => undefined,
}
const storage = createNamespaceStorage(driver, "g", { delay: 10_000 })
const read = storage.getItem("model")
void storage.setItem("model", "local")
void storage.flush()
storage.accept({ model: "other-window-older" }, [], 1)
loaded.resolve({ items: { model: "snapshot-older" }, revision: 0 })
expect(await read).toBe("local")
accepted.resolve(2)
await storage.flush()
expect(await storage.getItem("model")).toBe("local")
storage.accept({ model: "other-window-newer" }, [], 3)
expect(await storage.getItem("model")).toBe("other-window-newer")
})
test("a failed batch does not requeue a value a later batch already replaced", async () => {
const h = host()
const first = Promise.withResolvers<void>()
h.setGate(first.promise)
// The first batch (old) is held at the host and will be rejected; the second (new) succeeds.
h.setFail((insert) => insert.tabs === "old")
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
void storage.setItem("tabs", "old")
void storage.flush()
h.setGate(undefined)
void storage.setItem("tabs", "new")
void storage.flush()
await wait(0)
expect(h.data.get("w")?.get("tabs")).toBe("new")
first.resolve()
await storage.flush()
await storage.flush()
expect(h.updates().map((call) => call.insert)).toEqual([{ tabs: "old" }, { tabs: "new" }])
expect(h.data.get("w")?.get("tabs")).toBe("new")
expect(await storage.getItem("tabs")).toBe("new")
})
test("an event that reaches a window after a newer ack for the same key is ignored", async () => {
const h = host({ g: { model: "start" } })
const one = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
const two = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
await one.getItem("model")
await two.getItem("model")
void one.setItem("model", "A")
await one.flush()
void two.setItem("model", "B")
await two.flush()
// Both writes are acked. Now the event for A, which the host applied before B, reaches two.
h.deliver(two, 0)
expect(await two.getItem("model")).toBe("B")
expect(h.data.get("g")?.get("model")).toBe("B")
// One still receives B, which is newer than its own ack.
h.deliver(one, 1)
expect(await one.getItem("model")).toBe("B")
})
test("an event held back during an in-flight write wins after the ack if the host applied it later", async () => {
const h = host()
const gate = Promise.withResolvers<void>()
h.setGate(gate.promise)
const storage = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
void storage.setItem("model", "mine")
void storage.flush()
// Another window's write for the same key landed at the host after ours will.
storage.accept({ model: "theirs" }, [], 2)
expect(await storage.getItem("model")).toBe("mine")
gate.resolve()
await storage.flush()
expect(await storage.getItem("model")).toBe("theirs")
})
test("the initial load removes a key an older event inserted while the load was in flight", async () => {
const loaded = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
const driver: NamespaceDriver = { items: () => loaded.promise, update: async () => 0, clear: async () => undefined }
const storage = createNamespaceStorage(driver, "g", { delay: 10_000 })
const read = storage.getItem("model")
// Host history: insert at 41, delete at 42; the snapshot was taken at 42.
storage.accept({ model: "inserted" }, [], 41)
loaded.resolve({ items: {}, revision: 42 })
expect(await read).toBeNull()
// The delete event is older than the floor and must stay a no-op either way.
storage.accept({}, ["model"], 42)
expect(await storage.getItem("model")).toBeNull()
// A key inserted by an event newer than the snapshot survives the load.
const second = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
const other = createNamespaceStorage({ ...driver, items: () => second.promise }, "g", { delay: 10_000 })
const pending = other.getItem("model")
other.accept({ model: "after-snapshot" }, [], 43)
second.resolve({ items: {}, revision: 42 })
expect(await pending).toBe("after-snapshot")
})
test("an event older than the initial load is ignored", async () => {
const h = host({ g: { model: "loaded" } })
void h.driver.update("g", { model: "loaded" }, [])
await wait(0)
const storage = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
await storage.getItem("model")
storage.accept({ model: "before-load" }, [], 1)
expect(await storage.getItem("model")).toBe("loaded")
storage.accept({}, ["model"], 2)
expect(await storage.getItem("model")).toBeNull()
})
test("clear drops the cache and queued changes and clears the driver", async () => {
const h = host({ w: { tabs: "[]" } })
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
await storage.getItem("tabs")
void storage.setItem("recent", "{}")
await storage.clear()
expect(await storage.getItem("tabs")).toBeNull()
expect(await storage.getItem("recent")).toBeNull()
expect(h.calls.map((call) => call.kind)).toEqual(["items", "clear"])
})
})
@@ -1,159 +0,0 @@
import type { AsyncStorage } from "@solid-primitives/storage"
// The host-side store for one namespace: one bulk read, one bulk write. The host stamps every
// update with a monotonic revision and reports it with reads, acks, and change events.
export type NamespaceDriver = {
items(name: string): Promise<{ items: Record<string, string>; revision: number }>
update(name: string, insert: Record<string, string>, remove: string[]): Promise<number>
clear(name: string): Promise<void>
}
export type NamespaceStorage = AsyncStorage & {
/** Write every queued change now. Resolves when the driver has accepted it. */
flush(): Promise<void>
/** Apply a change another window made at `revision`, unless this window already holds something newer. */
accept(insert: Record<string, string>, remove: string[], revision: number): void
}
export const namespaceFlushDelay = 100
// In-memory truth for a namespace. Reads load the namespace once and are Map lookups from then
// on; writes update the cache immediately and are batched into one driver call per flush window,
// so a burst of setter calls costs one round trip. Mirrors VS Code's Storage class.
//
// Two orderings keep the cache correct. Every local write gets a sequence number that stays
// recorded until the host acks that exact write; while recorded, nothing external may replace
// the key. Every value the cache holds also carries the host revision it came from, so an event
// that reaches this window after a newer ack or load is recognised as stale and dropped. Batches
// are posted as soon as they are cut, never behind an earlier reply, so a flush on pagehide is
// on the wire before the page goes away.
export function createNamespaceStorage(
driver: NamespaceDriver,
name: string,
options: { delay?: number } = {},
): NamespaceStorage {
const delay = options.delay ?? namespaceFlushDelay
const cache = new Map<string, string>()
const local = new Map<string, { seq: number; value: string | null }>()
const dirty = new Set<string>()
const inflight = new Set<Promise<void>>()
// Host revision behind each cached key, and the revision the initial load reflected for all keys.
const applied = new Map<string, number>()
let floor = -1
// The newest external change for a key that arrived while a local write was still in flight.
const deferred = new Map<string, { revision: number; value: string | null }>()
let seq = 0
let loading: Promise<void> | undefined
let timer: ReturnType<typeof setTimeout> | undefined
const place = (key: string, value: string | null, revision: number) => {
if (value === null) cache.delete(key)
else cache.set(key, value)
applied.set(key, revision)
}
// The snapshot is the whole truth at its revision: a key it lacks was deleted by then, even if
// an older event inserted it into the cache while the load was in flight.
const load = () =>
(loading ??= driver.items(name).then((loaded) => {
floor = loaded.revision
const stale = (key: string) => !local.has(key) && (applied.get(key) ?? -1) <= loaded.revision
for (const key of [...cache.keys()]) {
if (!(key in loaded.items) && stale(key)) place(key, null, loaded.revision)
}
for (const [key, value] of Object.entries(loaded.items)) {
if (stale(key)) place(key, value, loaded.revision)
}
}))
const write = (key: string, value: string | null) => {
if (value === null) cache.delete(key)
else cache.set(key, value)
local.set(key, { seq: ++seq, value })
dirty.add(key)
timer ??= setTimeout(() => void flush(), delay)
}
// The host accepted this window's value for `key` at `revision`. A change from another window
// that was held back meanwhile wins if the host applied it later than ours.
const acknowledge = (key: string, revision: number) => {
local.delete(key)
const later = deferred.get(key)
deferred.delete(key)
if (later && later.revision > revision) return place(key, later.value, later.revision)
applied.set(key, revision)
}
const flush = () => {
clearTimeout(timer)
timer = undefined
if (dirty.size > 0) {
const batch = [...dirty].map((key) => ({ key, ...local.get(key)! }))
dirty.clear()
const insert = Object.fromEntries(batch.filter((entry) => entry.value !== null).map((e) => [e.key, e.value!]))
const remove = batch.filter((entry) => entry.value === null).map((entry) => entry.key)
const current = (entry: { key: string; seq: number }) => local.get(entry.key)?.seq === entry.seq
const request = driver
.update(name, insert, remove)
.then((revision) => batch.filter(current).forEach((entry) => acknowledge(entry.key, revision)))
.catch((error: unknown) => {
// Only a value nothing newer has replaced is worth retrying.
batch.filter(current).forEach((entry) => dirty.add(entry.key))
console.error(`[persistence] flush failed for ${name}`, error)
})
.finally(() => inflight.delete(request))
inflight.add(request)
}
return Promise.all(inflight).then(() => undefined)
}
const storage: NamespaceStorage = {
getItem: async (key) => {
await load()
return cache.get(key) ?? null
},
setItem: async (key, value) => write(key, value),
removeItem: async (key) => write(key, null),
clear: async () => {
clearTimeout(timer)
timer = undefined
cache.clear()
local.clear()
dirty.clear()
applied.clear()
deferred.clear()
loading = Promise.resolve()
await driver.clear(name)
},
key: async (index: number) => {
await load()
return [...cache.keys()][index]
},
getLength: async () => {
await load()
return cache.size
},
get length() {
return storage.getLength()
},
flush,
accept(insert, remove, revision) {
// The initial load already reflects everything up to `floor`.
if (revision <= floor) return
const changes = [
...Object.entries(insert).map(([key, value]) => [key, value] as const),
...remove.map((key) => [key, null] as const),
]
for (const [key, value] of changes) {
if (local.has(key)) {
const held = deferred.get(key)
if (!held || revision > held.revision) deferred.set(key, { revision, value })
continue
}
if (revision <= (applied.get(key) ?? floor)) continue
place(key, value, revision)
}
},
}
return storage
}
@@ -1,156 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { AsyncStorage, PersistenceSyncAPI, PersistenceSyncCallback, SyncStorage } from "@solid-primitives/storage"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { flushPersisted, persistStore } from "./persist"
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
type State = { count: number; label: string }
function setup(input: { initial?: string | null | Promise<string | null>; delay?: number; sync?: PersistenceSyncAPI }) {
const writes: string[] = []
return createRoot((dispose) => {
const [store, setStore] = createStore<State>({ count: 0, label: "" })
const persist = persistStore({
store,
setStore,
name: "state",
// One fixture serves both the sync and the async storage shape.
storage: {
getItem: () => input.initial ?? null,
setItem: (_key: string, value: string) => {
writes.push(value)
},
removeItem: () => {},
} as SyncStorage | AsyncStorage,
serialize: JSON.stringify,
deserialize: JSON.parse,
sync: input.sync,
delay: input.delay ?? 10,
})
return { store, set: persist.setStore, persist, writes, dispose }
})
}
describe("persistStore", () => {
test("marks the store dirty on set and writes once after the delay", async () => {
const value = setup({})
value.set("count", 1)
value.set("count", 2)
value.set("label", "a")
expect(value.store.count).toBe(2)
expect(value.writes).toEqual([])
await wait(30)
expect(value.writes).toEqual([JSON.stringify({ count: 2, label: "a" })])
value.dispose()
})
test("skips the write when the serialized value did not change", () => {
const value = setup({ delay: 10_000 })
value.set("count", 1)
value.persist.flush()
value.set("count", 1)
value.persist.flush()
expect(value.writes).toHaveLength(1)
value.dispose()
})
test("hydrates synchronously from sync storage without writing back", () => {
const value = setup({ initial: JSON.stringify({ count: 5, label: "saved" }), delay: 10_000 })
expect(value.store).toEqual({ count: 5, label: "saved" })
value.persist.flush()
expect(value.writes).toEqual([])
value.dispose()
})
test("a set made while async storage loads wins over the loaded value", async () => {
const loading = Promise.withResolvers<string | null>()
const value = setup({ initial: loading.promise, delay: 10_000 })
value.set("count", 9)
loading.resolve(JSON.stringify({ count: 1, label: "old" }))
await loading.promise
expect(value.store.count).toBe(9)
value.dispose()
})
test("applies another window's value when clean and ignores it while dirty", () => {
const listeners: PersistenceSyncCallback[] = []
const sent: string[] = []
const value = setup({
delay: 10_000,
sync: [(subscriber) => listeners.push(subscriber), (_key, next) => sent.push(String(next))],
})
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 3, label: "remote" }), timeStamp: 0 })
expect(value.store).toEqual({ count: 3, label: "remote" })
value.set("label", "local")
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 4, label: "remote-2" }), timeStamp: 0 })
expect(value.store).toEqual({ count: 3, label: "local" })
value.persist.flush()
expect(sent).toEqual([JSON.stringify({ count: 3, label: "local" })])
value.dispose()
})
test("a remote value arriving during a no-op local set is adopted when the save finds no change", () => {
const listeners: PersistenceSyncCallback[] = []
const sent: string[] = []
const value = setup({
delay: 10_000,
sync: [(subscriber) => listeners.push(subscriber), (_key, next) => sent.push(String(next))],
})
value.set("count", 1)
value.persist.flush()
// Setting the same value again marks the store dirty without changing it.
value.set("count", 1)
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 1, label: "remote" }), timeStamp: 0 })
expect(value.store.label).toBe("")
value.persist.flush()
expect(value.store).toEqual({ count: 1, label: "remote" })
expect(value.writes).toHaveLength(1)
expect(sent).toHaveLength(1)
// A later save must not consider the adopted value a local change.
value.set("count", 1)
value.persist.flush()
expect(value.writes).toHaveLength(1)
value.dispose()
})
test("a remote revert to the saved value during a no-op local set clears an earlier held change", () => {
const listeners: PersistenceSyncCallback[] = []
const value = setup({ delay: 10_000, sync: [(subscriber) => listeners.push(subscriber), () => {}] })
value.set("label", "saved")
value.persist.flush()
value.set("label", "saved")
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 0, label: "changed" }), timeStamp: 0 })
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 0, label: "saved" }), timeStamp: 0 })
value.persist.flush()
expect(value.store).toEqual({ count: 0, label: "saved" })
expect(value.writes).toHaveLength(1)
value.dispose()
})
test("a remote value arriving during a real local change is dropped in favour of the local one", () => {
const listeners: PersistenceSyncCallback[] = []
const value = setup({ delay: 10_000, sync: [(subscriber) => listeners.push(subscriber), () => {}] })
value.set("count", 1)
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 9, label: "remote" }), timeStamp: 0 })
value.persist.flush()
expect(value.store).toEqual({ count: 1, label: "" })
expect(value.writes).toEqual([JSON.stringify({ count: 1, label: "" })])
value.dispose()
})
test("disposing the owner and flushPersisted both save pending changes", () => {
const first = setup({ delay: 10_000 })
first.set("count", 1)
first.dispose()
expect(first.writes).toHaveLength(1)
const second = setup({ delay: 10_000 })
second.set("count", 2)
flushPersisted()
expect(second.writes).toEqual([JSON.stringify({ count: 2, label: "" })])
second.dispose()
expect(second.writes).toHaveLength(1)
})
})
@@ -1,103 +0,0 @@
import type { AsyncStorage, PersistenceSyncAPI, SyncStorage } from "@solid-primitives/storage"
import { getOwner, onCleanup, untrack } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
export const persistSaveDelay = 100
const pending = new Set<() => void>()
/** Serialize and write every store with unsaved changes now. */
export function flushPersisted() {
for (const save of [...pending]) save()
}
// Covers synchronous web storage. Desktop registers its own pagehide handling earlier than this
// module loads, so its shutdown path calls flushPersisted() itself before flushing namespaces.
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flushPersisted()
})
window.addEventListener("pagehide", flushPersisted)
}
// A store whose serialized form is written to storage on a schedule instead of on every setter
// call. The setter only marks the store dirty; serialization happens once per save window, once
// per owner cleanup, and when the page hides. Mirrors VS Code's Memento.
export function persistStore<T extends object>(input: {
store: Store<T>
setStore: SetStoreFunction<T>
name: string
storage: SyncStorage | AsyncStorage
serialize: (value: T) => string
deserialize: (raw: string) => T
sync?: PersistenceSyncAPI
delay?: number
/** Replaces `storage.setItem` for stores whose storage can take the value itself. */
write?: (value: T, serialized: string) => void
}) {
const delay = input.delay ?? persistSaveDelay
let dirty = false
let touched = false
let last: string | undefined
// The newest value another window wrote while this store was dirty; applied at save time if the
// local setter calls turned out not to change anything.
let remote: string | undefined
let timer: ReturnType<typeof setTimeout> | undefined
const save = () => {
clearTimeout(timer)
timer = undefined
pending.delete(save)
if (!dirty) return
dirty = false
const held = remote
remote = undefined
const next = untrack(() => input.serialize(input.store))
if (next === last) {
if (held !== undefined && held !== last) hydrate(held)
return
}
last = next
input.sync?.[1](input.name, next)
if (input.write) return input.write(input.store, next)
void input.storage.setItem(input.name, next)
}
// Solid's setter overloads are too deep to spread generically; the wrapper only forwards.
const apply = input.setStore as unknown as (...values: unknown[]) => void
const setStore = ((...values: unknown[]) => {
apply(...values)
dirty = true
touched = true
pending.add(save)
timer ??= setTimeout(save, delay)
}) as unknown as SetStoreFunction<T>
const hydrate = (raw: string) => {
last = raw
input.setStore(reconcile(input.deserialize(raw)))
}
const init = input.storage.getItem(input.name)
// A value the user already changed is newer than whatever storage held.
if (init instanceof Promise) void init.then((raw) => raw && !touched && hydrate(raw))
else if (init) hydrate(init)
input.sync?.[0]((data) => {
if (data.key !== input.name || (data.url ?? location.href) !== location.href) return
if (!data.newValue) return
// A real unsaved local change wins over another window's write, as in VS Code's storage
// service; whether the change is real is only known when the store is serialized. Every
// remote value replaces the held one, including a revert to `last`, so the save sees the
// other window's final state rather than an intermediate one.
if (dirty) {
remote = data.newValue
return
}
if (data.newValue === last) return
hydrate(data.newValue)
})
if (getOwner()) onCleanup(save)
return { setStore, init, flush: save }
}
@@ -1,15 +1,14 @@
import { Platform, usePlatform } from "@/runtime/platform/platform"
import { messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { makePersisted, messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { checksum } from "@opencode-ai/util/encode"
import { createResource, onCleanup, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Option, Schema } from "effect"
import { pathKey } from "@/workspaces/path-key"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
import { persistStore } from "./persist"
import { Persistence } from "./schema"
type InitType = Promise<string | null> | string | null
type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [
Store<T>,
SetStoreFunction<T>,
@@ -484,7 +483,6 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
const initialized = Persistence.withInitial(schema, initial)
const json = Schema.fromJsonString(initialized)
const decode = Schema.decodeUnknownOption(json)
const encode = Schema.encodeSync(initialized)
const serialize = Schema.encodeSync(json)
const normalize = (raw: string) => {
const value = decode(raw)
@@ -493,12 +491,10 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
const store = createStore<S["Type"]>(Schema.decodeUnknownSync(Schema.toType(initialized))(initial))
const isDesktop = platform.platform === "desktop" && !!platform.storage
const draft = config.draft ? platform.draftStore : undefined
const prefix = `${config.storage ?? "default"}:`
// The newest serialized draft, replayed into storage if a slow load finishes after an edit.
let draftLatest: string | undefined
const currentStorage = (() => {
if (draft) {
const prefix = `${config.storage ?? "default"}:`
return {
getItem: (key: string) => draft.getItem(prefix + key),
setItem: (key: string, value: string) => draft.setItem(prefix + key, value),
@@ -560,6 +556,7 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
]
.filter((source): source is { storage: SyncStorage | AsyncStorage; key?: string } => !!source?.storage)
.map((source) => ({ ...source, storage: toAsyncStorage(source.storage) }))
let draftLatest: string | undefined
const api: AsyncStorage = {
getItem: async (key) => {
@@ -596,29 +593,13 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
: undefined
if (channel) onCleanup(() => channel.close())
const persist = persistStore({
store: store[0],
setStore: store[1],
const [state, setState, init] = makePersisted<S["Type"], typeof store>(store, {
name: config.key,
storage,
serialize,
deserialize: Schema.decodeUnknownSync(json),
sync: channel ? messageSync(channel) : undefined,
// Drafts take the encoded document itself so large text is externalized without the store
// re-parsing the serialized form on every save.
write: draft
? (value, serialized) => {
draftLatest = serialized
// A failed chunk upload is retried by the next save; see drafts.ts.
void draft
.setDocument(prefix + config.key, encode(value))
.catch((error: unknown) => console.error(`[persistence] draft write failed for ${config.key}`, error))
}
: undefined,
})
const state = store[0]
const setState = persist.setStore
const init = persist.init
const isAsync = init instanceof Promise
const [ready] = createResource(
@@ -1,33 +0,0 @@
import { onCleanup, onMount } from "solid-js"
export function KeyboardInsets() {
onMount(() => {
const viewport = window.visualViewport
if (!viewport) return
const root = document.documentElement
const sync = () => {
const active = document.activeElement
const editing =
active instanceof HTMLElement &&
(active.isContentEditable || active.matches("input:not([readonly]), textarea:not([readonly])"))
// iOS retains the home-indicator inset above its keyboard. Ignore pinch zoom
// and small viewport changes from browser chrome, not just editor focus.
const keyboard = editing && root.clientHeight - viewport.height * viewport.scale > 100
if (keyboard) root.style.setProperty("--safe-area-inset-bottom", "0px")
if (!keyboard) root.style.removeProperty("--safe-area-inset-bottom")
}
sync()
viewport.addEventListener("resize", sync)
window.addEventListener("resize", sync)
document.addEventListener("focusin", sync)
document.addEventListener("focusout", sync)
onCleanup(() => {
viewport.removeEventListener("resize", sync)
window.removeEventListener("resize", sync)
document.removeEventListener("focusin", sync)
document.removeEventListener("focusout", sync)
root.style.removeProperty("--safe-area-inset-bottom")
})
})
return null
}
@@ -255,7 +255,7 @@ export function createChildStoreManager(input: {
disposers.set(key, dispose)
activationToggles.set(key, setInstanceQueriesEnabled)
const onPersistedInit = (init: Promise<string | null> | string | null, run: () => void) => {
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return
void init.then(() => {
if (children[key] !== child) return
@@ -1,13 +1,7 @@
import { describe, expect, test } from "bun:test"
import { createRequestQueue, isSetupRequest, isSlowRequest } from "./request-queue"
import { createRequestQueue, isSlowRequest } from "./request-queue"
function setup(input?: {
limit?: number
slowLimit?: number
stallMs?: number
headersTimeoutMs?: number
setupHeadersTimeoutMs?: number
}) {
function setup(input?: { limit?: number; slowLimit?: number; stallMs?: number; headersTimeoutMs?: number }) {
const pending: Array<{ url: string; signal: AbortSignal; resolve: () => void }> = []
const logs: Array<{ message: string; data: Record<string, unknown> }> = []
let clock = 0
@@ -16,13 +10,12 @@ function setup(input?: {
slowLimit: input?.slowLimit,
stallMs: input?.stallMs,
headersTimeoutMs: input?.headersTimeoutMs,
setupHeadersTimeoutMs: input?.setupHeadersTimeoutMs,
now: () => clock,
log: (message, data) => logs.push({ message, data }),
fetch: Object.assign(
(resource: RequestInfo | URL, init?: RequestInit) =>
(resource: RequestInfo | URL) =>
new Promise<Response>((resolve, reject) => {
const request = new Request(resource, init)
const request = new Request(resource)
request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true })
pending.push({ url: request.url, signal: request.signal, resolve: () => resolve(new Response("ok")) })
}),
@@ -50,12 +43,7 @@ describe("createRequestQueue", () => {
test("slow endpoints hold at most their share of slots so small reads go first", async () => {
const input = setup({ limit: 4, slowLimit: 2 })
const paths = [
"/api/vcs?location[directory]=%2Fa",
"/api/vcs/diff?location[directory]=%2Fa",
"/api/worktree",
"/api/session/ses_1",
]
const paths = ["/api/vcs?location[directory]=%2Fa", "/api/vcs/diff?location[directory]=%2Fa", "/api/worktree", "/api/session/ses_1"]
const responses = paths.map((path) => input.queue.fetch(`http://server${path}`))
await input.settle()
const started = () => input.pending.map((item) => new URL(item.url).pathname)
@@ -121,26 +109,6 @@ describe("createRequestQueue", () => {
expect(input.queue.inflight()).toBe(0)
})
test("worktree creation gets the setup deadline while worktree reads keep the normal one", async () => {
const input = setup({ limit: 4, headersTimeoutMs: 10, setupHeadersTimeoutMs: 200 })
const create = input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa", { method: "POST" })
const list = input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa")
const listError = await list.catch((cause: unknown) => cause)
expect((listError as DOMException).name).toBe("TimeoutError")
// Past the normal deadline, the create is still on the wire.
expect(input.pending[0]!.signal.aborted).toBe(false)
input.pending[0]!.resolve()
await expect(create).resolves.toBeInstanceOf(Response)
expect(input.queue.inflight()).toBe(0)
})
test("only worktree creation counts as a setup request", () => {
expect(isSetupRequest("POST", "/api/worktree")).toBe(true)
expect(isSetupRequest("GET", "/api/worktree")).toBe(false)
expect(isSetupRequest("POST", "/api/worktree/refresh")).toBe(false)
expect(isSetupRequest("DELETE", "/api/worktree")).toBe(false)
})
test("caller aborts still reach the underlying request", async () => {
const input = setup({ limit: 1 })
const controller = new AbortController()
@@ -19,26 +19,16 @@ export const requestStallMs = 2_000
// instead of wedging every later API call; the body may still stream for as long as it needs.
export const requestHeadersTimeoutMs = 60_000
// Creating a worktree runs the project's setup script (dependency installs, fetches) before the
// server answers, so it needs a budget measured in minutes rather than seconds. Cutting it off
// kills the script midway and leaves a registered but half-initialised worktree behind.
export const setupRequestHeadersTimeoutMs = 10 * 60_000
export function isSlowRequest(pathname: string) {
return slowRequestPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))
}
export function isSetupRequest(method: string, pathname: string) {
return method === "POST" && pathname === "/api/worktree"
}
export function createRequestQueue(input: {
fetch: typeof globalThis.fetch
limit?: number
slowLimit?: number
stallMs?: number
headersTimeoutMs?: number
setupHeadersTimeoutMs?: number
log?: (message: string, data: Record<string, unknown>) => void
now?: () => number
}) {
@@ -46,7 +36,6 @@ export function createRequestQueue(input: {
const slowLimit = input.slowLimit ?? requestQueueSlowLimit
const stallMs = input.stallMs ?? requestStallMs
const headersTimeoutMs = input.headersTimeoutMs ?? requestHeadersTimeoutMs
const setupHeadersTimeoutMs = input.setupHeadersTimeoutMs ?? setupRequestHeadersTimeoutMs
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
const base = input.fetch
const now = input.now ?? Date.now
@@ -112,7 +101,7 @@ export function createRequestQueue(input: {
request.signal.addEventListener("abort", () => controller.abort(request.signal.reason), { once: true })
const timer = setTimeout(
() => controller.abort(new DOMException("Timed out waiting for the server to respond", "TimeoutError")),
isSetupRequest(request.method, pathname) ? setupHeadersTimeoutMs : headersTimeoutMs,
headersTimeoutMs,
)
return base(new Request(request, { signal: controller.signal })).finally(() => {
clearTimeout(timer)
@@ -91,7 +91,7 @@ export function SessionProjectMenu(props: {
when={props.showProjectIcon}
fallback={
<span class={props.workspace ? "text-v2-icon-icon-accent" : "text-v2-icon-icon-muted"}>
<Icon name={props.workspace ? "outline-worktree" : "monitor"} />
<Icon name={props.workspace ? "workspace-isolated" : "monitor"} />
</span>
}
>
@@ -184,7 +184,7 @@ function WorkspaceMoveAction(props: {
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pe-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
}
>
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
<Icon name="workspace-new" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
</SessionWorkspaceMenu>
<button
@@ -256,10 +256,7 @@ export function SessionSummaryPanel(props: {
gutter={props.mobile ? 4 : -22}
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
>
<Icon
name={props.local ? "monitor" : "outline-worktree"}
class={`shrink-0 ${props.local ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
/>
<Icon name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
{location()}
</span>
@@ -7,6 +7,7 @@ import type { Project } from "@/runtime/server/types"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { useSettingsDialog } from "@/settings/command"
import { pathKey } from "@/workspaces/path-key"
import { showToast } from "@/shell/notifications/toast"
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/workspaces/paths"
@@ -27,6 +28,7 @@ export function SessionWorkspaceMenu(props: {
const language = useLanguage()
const serverSDK = useServerSDK()
const data = useData()
const openWorkspaces = useSettingsDialog("workspaces")
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
const blocked = () => props.eligible === false || data.session.status(props.sessionID) === "running"
@@ -97,13 +99,13 @@ export function SessionWorkspaceMenu(props: {
</Menu.Item>
</Show>
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Icon name="plus" />
<Icon name="workspace-new" />
{language.t("workspace.new")}
</Menu.Item>
<Show when={workspaces().length > 0}>
<Menu.Sub gutter={0} overlap overflowPadding={8}>
<Menu.SubTrigger>
<Icon name="outline-worktree" />
<Icon name="workspace-isolated" />
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</Menu.SubTrigger>
<Menu.Portal>
@@ -111,7 +113,7 @@ export function SessionWorkspaceMenu(props: {
<For each={workspaces()}>
{(workspace) => (
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
<Icon name="outline-worktree" />
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
</Menu.Item>
)}
@@ -121,6 +123,10 @@ export function SessionWorkspaceMenu(props: {
</Menu.Sub>
</Show>
</Menu.Group>
<Menu.Separator class="h-[0.5px] bg-v2-border-border-base" />
<Menu.Item onSelect={() => openWorkspaces()}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
+13 -101
View File
@@ -1029,92 +1029,25 @@
display: flex;
flex-direction: column;
gap: 0;
padding: 16px;
border-radius: 8px;
background-color: var(--v2-background-bg-layer-01);
padding: 20px;
border-radius: 6px;
background-color: var(--v2-background-bg-base);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
}
.settings-workspaces-inventory[data-empty="true"] [data-component="settings-list"] {
padding: 0;
background-color: transparent;
box-shadow: none;
}
.settings-workspaces-row-motion {
display: grid;
grid-template-rows: minmax(0, 1fr);
opacity: 1;
transition:
grid-template-rows 150ms ease-out,
opacity 120ms ease-out;
}
.settings-workspaces-row-motion[data-removing="true"] {
grid-template-rows: minmax(0, 0fr);
opacity: 0;
}
.settings-workspaces-row {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 12px;
overflow: hidden;
transition:
padding-bottom 150ms ease-out,
margin-bottom 150ms ease-out,
border-color 120ms ease-out;
}
.settings-workspaces-row-motion:not(:last-child) > .settings-workspaces-row {
.settings-workspaces-row:not(:last-child) {
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-workspaces-row-motion:has(+ .settings-workspaces-row-motion[data-removing="true"]:last-child)
> .settings-workspaces-row {
padding-bottom: 0;
margin-bottom: 0;
border-color: transparent;
}
.settings-workspaces-empty-motion {
display: grid;
min-height: 0;
grid-template-rows: minmax(0, 0fr);
opacity: 0;
overflow: hidden;
pointer-events: none;
transition:
grid-template-rows 150ms ease-out,
opacity 120ms ease-out;
}
.settings-workspaces-empty-motion[data-visible="true"] {
grid-template-rows: minmax(0, 1fr);
opacity: 1;
pointer-events: auto;
}
.settings-workspaces-empty-motion > .settings-workspaces-empty {
min-height: 0;
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
.settings-workspaces-row-motion {
transition: none;
}
.settings-workspaces-row,
.settings-workspaces-empty-motion {
transition: none;
}
}
.settings-workspaces-row-header {
display: flex;
min-width: 0;
@@ -1155,7 +1088,7 @@
color: var(--v2-text-text-base);
font-family: inherit;
font-size: 13px;
font-weight: 440;
font-weight: 530;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
text-overflow: ellipsis;
@@ -1165,11 +1098,6 @@
cursor: default;
}
.settings-workspaces-path-name {
color: var(--v2-text-text-base);
font-weight: 530;
}
.settings-workspaces-meta {
font-size: 13px;
font-weight: 440;
@@ -1177,10 +1105,6 @@
color: var(--v2-text-text-faint);
}
.settings-workspaces-meta-project {
color: var(--v2-text-text-muted);
}
.settings-workspaces-active,
.settings-workspaces-more {
flex-shrink: 0;
@@ -1190,16 +1114,11 @@
color: var(--v2-text-text-faint);
}
.settings-workspaces-active {
font-size: 13px;
line-height: var(--line-height-compact);
}
.settings-workspaces-sessions {
display: flex;
flex-direction: column;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 6px;
border-radius: 4px;
background-color: var(--v2-background-bg-base);
overflow: hidden;
}
@@ -1210,8 +1129,7 @@
align-items: center;
justify-content: space-between;
gap: 12px;
padding-block: 10px;
padding-inline: 12px 16px;
padding: 10px 12px;
font-size: 13px;
font-weight: 440;
line-height: 16px;
@@ -1231,28 +1149,22 @@
.settings-workspaces-session-time {
flex-shrink: 0;
font-size: 13px;
line-height: var(--line-height-compact);
color: var(--v2-text-text-faint);
font-size: 11px;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-workspaces-empty {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-start;
gap: 4px;
align-items: center;
justify-content: center;
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
}
.settings-workspaces-empty-title {
color: var(--v2-text-text-base);
font-weight: 530;
}
@media (max-width: 639px) {
.settings-workspaces-header {
padding: 24px 20px 20px;
+4 -21
View File
@@ -1,5 +1,4 @@
import { Component, createEffect, createMemo, For, Show, onMount, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { Menu } from "@opencode-ai/ui/menu"
@@ -36,7 +35,7 @@ const sections = [
[
{ value: "servers", icon: "server", label: "status.popover.tab.servers" },
{ value: "projects", icon: "folder", label: "settings.tab.projects" },
{ value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" },
{ value: "workspaces", icon: "workspace-isolated", label: "settings.tab.workspaces" },
],
[
{ value: "providers", icon: "providers", label: "settings.providers.title" },
@@ -55,7 +54,6 @@ export const SettingsScreen: Component = () => {
const servers = useServers()
const tabs = useTabs()
const global = useGlobal()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
let root: HTMLDivElement | undefined
onMount(() => {
@@ -148,14 +146,7 @@ export const SettingsScreen: Component = () => {
</Show>
<For each={group}>
{(section) => (
<Menu.RadioItem
value={section.value}
closeOnSelect
onSelect={() => {
if (section.value === "workspaces")
setState("worktreeFilterReset", (value) => value + 1)
}}
>
<Menu.RadioItem value={section.value} closeOnSelect>
<Icon name={section.icon} />
{language.t(section.label)}
</Menu.RadioItem>
@@ -181,12 +172,7 @@ export const SettingsScreen: Component = () => {
<div class="flex flex-col gap-1 w-full">
<For each={group}>
{(section) => (
<Tabs.Trigger
value={section.value}
onClick={() => {
if (section.value === "workspaces") setState("worktreeFilterReset", (value) => value + 1)
}}
>
<Tabs.Trigger value={section.value}>
<Icon name={section.icon} />
{language.t(section.label)}
</Tabs.Trigger>
@@ -222,10 +208,7 @@ export const SettingsScreen: Component = () => {
</Tabs.Content>
<SettingsServerScope directory={directory()}>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces
activeDirectory={directory()}
resetProjectFilter={() => state.worktreeFilterReset}
/>
<SettingsWorkspaces activeDirectory={directory()} />
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={directory()} onBack={showProviders} />
+59 -123
View File
@@ -1,6 +1,4 @@
[data-component="timeline-detail-control"] {
--timeline-detail-track-background: var(--v2-background-bg-layer-04);
--timeline-detail-marker-background: var(--v2-grey-500);
position: relative;
display: flex;
min-width: 0;
@@ -12,55 +10,48 @@
letter-spacing: -0.04px;
container: timeline-detail / inline-size;
[data-slot="timeline-detail-heading"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 24px;
font-weight: 530;
line-height: var(--line-height-compact);
}
[data-slot="timeline-detail-current"] {
color: var(--v2-text-text-muted);
text-align: end;
}
p {
margin: 0;
color: var(--v2-text-text-muted);
}
[data-slot="timeline-detail-summary"] {
color: var(--v2-text-text-base);
}
[data-slot="timeline-detail-scale"] {
position: relative;
height: 28px;
margin-top: 24px;
margin-inline: 8px;
}
[data-slot="timeline-detail-track"] {
position: absolute;
inset-inline: 0;
inset-inline: 8px;
top: 12px;
height: 4px;
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 2px;
background: var(--timeline-detail-track-background);
background: var(--v2-background-bg-layer-03);
pointer-events: none;
&::before {
content: "";
position: absolute;
inset-inline-start: 0;
width: var(--timeline-detail-progress);
height: 100%;
border-radius: inherit;
background: var(--v2-background-bg-accent);
}
span {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
box-sizing: content-box;
width: 6px;
height: 6px;
border: 2px solid var(--v2-background-bg-layer-01);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--timeline-detail-marker-background);
&:dir(rtl) {
transform: translate(50%, -50%);
}
background: var(--v2-border-border-strong);
}
}
@@ -68,11 +59,9 @@
appearance: none;
position: relative;
display: block;
/* Native ranges reserve half the thumb width at each end of their track. */
width: calc(100% + 16px);
width: 100%;
height: 28px;
margin: 0;
margin-inline: -8px;
background: transparent;
cursor: pointer;
border-radius: 4px;
@@ -92,15 +81,9 @@
width: 16px;
height: 16px;
margin-top: -6px;
border: 0;
border: 1px solid var(--v2-border-border-base);
border-radius: 50%;
background:
linear-gradient(
180deg,
var(--v2-overlay-gradient-depth-overlay-depth-top) 0%,
var(--v2-overlay-gradient-depth-overlay-depth-bot) 100%
),
var(--v2-grey-300);
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-button-neutral);
}
@@ -113,34 +96,28 @@
box-sizing: border-box;
width: 16px;
height: 16px;
border: 0;
border: 1px solid var(--v2-border-border-base);
border-radius: 50%;
background:
linear-gradient(
180deg,
var(--v2-overlay-gradient-depth-overlay-depth-top) 0%,
var(--v2-overlay-gradient-depth-overlay-depth-bot) 100%
),
var(--v2-grey-300);
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-button-neutral);
}
}
[data-slot="timeline-detail-advanced"] {
margin-top: 20px;
margin-top: 4px;
padding-top: 8px;
}
[data-slot="timeline-detail-advanced"] > [data-slot="collapsible-trigger"] {
width: fit-content;
height: 24px;
height: 28px;
align-self: flex-start;
gap: 6px;
font-size: inherit;
font-weight: 530;
line-height: var(--line-height-compact);
letter-spacing: inherit;
color: var(--v2-text-text-base);
color: var(--v2-text-text-muted);
[data-slot="collapsible-arrow"] {
width: 16px;
@@ -156,28 +133,20 @@
}
[data-slot="timeline-detail-categories"] {
--timeline-detail-columns: minmax(0, 1fr) 72px 88px;
--timeline-detail-columns: minmax(0, 1fr) 100px 108px;
display: flex;
flex-direction: column;
margin-top: 0;
margin-top: 8px;
}
[data-slot="timeline-detail-explainer"] {
margin-bottom: 12px;
}
[data-slot="timeline-detail-field-label"] {
display: none;
}
[data-slot="timeline-detail-unavailable"] {
width: 24px;
border-block-start: 1px solid var(--v2-icon-icon-muted);
opacity: 0.5;
}
[data-slot="timeline-detail-list"] {
border: 0.5px solid var(--v2-border-border-base);
border-radius: 8px;
background-color: var(--v2-background-bg-layer-02);
}
[data-slot="timeline-detail-columns"],
[data-slot="timeline-detail-category"] {
display: grid;
@@ -186,10 +155,6 @@
column-gap: 8px;
line-height: var(--line-height-compact);
> :nth-child(3) {
margin-inline-start: 8px;
}
> span {
min-width: 0;
overflow-wrap: normal;
@@ -197,78 +162,49 @@
}
[data-slot="timeline-detail-columns"] {
min-height: var(--line-height-compact);
margin-bottom: 8px;
padding-inline: 12.5px;
min-height: 28px;
color: var(--v2-text-text-muted);
span:not(:first-child) {
text-align: center;
padding-inline-start: 8px;
}
}
[data-slot="timeline-detail-category"] {
min-height: 40px;
padding-block: 8px;
padding-inline: 12px;
border-bottom: 0.5px solid var(--v2-border-border-base);
&:last-child {
border-bottom: 0;
}
[data-component="select-v2-root"],
[data-slot="timeline-detail-placement"],
[data-slot="timeline-detail-expansion"] {
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
&[data-hidden] [data-slot="timeline-detail-activity"] {
color: var(--v2-text-text-faint);
[data-component="select-v2-root"][data-field] {
width: 100%;
}
[data-component="select-v2"][data-appearance="inline"] {
width: 100%;
}
}
[data-slot="timeline-detail-activity"] {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
> label {
min-width: 0;
overflow-wrap: anywhere;
cursor: default;
}
[data-component="tooltip-v2-trigger"] {
flex-shrink: 0;
}
[data-action="timeline-detail-visibility"][aria-pressed="true"] {
color: var(--v2-icon-icon-base);
}
/* Labels also activate :hover on their associated button. Keep that feedback local to the icon. */
&:has(> label:hover) [data-action="timeline-detail-visibility"][data-variant="ghost-muted"] {
background-color: transparent;
}
}
}
[data-color-scheme="dark"] [data-component="timeline-detail-control"] {
--timeline-detail-track-background: var(--v2-background-bg-layer-03);
--timeline-detail-marker-background:
linear-gradient(
180deg,
var(--v2-overlay-gradient-depth-overlay-depth-top) 0%,
var(--v2-overlay-gradient-depth-overlay-depth-bot) 100%
),
var(--v2-grey-300);
}
@container timeline-detail (max-width: 250px) {
[data-component="timeline-detail-control"] [data-slot="timeline-detail-heading"] {
flex-wrap: wrap;
gap: 4px 8px;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-current"] {
margin-inline-start: auto;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"] {
display: none;
}
@@ -278,20 +214,20 @@
row-gap: 8px;
padding-block: 12px;
> :nth-child(3) {
margin-inline-start: 0;
> span {
font-weight: 530;
}
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-field-label"] {
display: block;
margin-bottom: 2px;
padding-inline-start: 8px;
font-size: 12px;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-placement"]:empty,
[data-component="timeline-detail-control"] [data-slot="timeline-detail-expansion"]:empty {
display: none;
}
@@ -299,7 +235,7 @@
@container timeline-detail (max-width: 310px) {
[data-component="timeline-detail-control"] [data-slot="timeline-detail-categories"] {
--timeline-detail-columns: minmax(0, 1fr) 64px 80px;
--timeline-detail-columns: minmax(0, 1fr) 90px 96px;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"],
@@ -1,40 +0,0 @@
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { timelinePresets, type TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
import { SettingsList } from "./list"
import { TimelineDetailControl } from "./timeline-detail"
import "./settings.css"
export default {
title: "OpenCode/Settings/Timeline detail",
id: "settings-timeline-detail",
}
export const Interactive = {
render: () => {
const [state, setState] = createStore<{ value: TimelineDetail; visible: boolean }>({
value: structuredClone(timelinePresets[2].value),
visible: true,
})
return (
<div class="flex w-[560px] max-w-full flex-col gap-4">
<Show when={state.visible}>
<SettingsList>
<div class="py-5">
<TimelineDetailControl value={state.value} onChange={(value) => setState("value", value)} />
</div>
</SettingsList>
</Show>
<Button onClick={() => setState("visible", !state.visible)}>
{state.visible ? "Leave settings" : "Return to settings"}
</Button>
<Button onClick={() => setState("value", structuredClone(timelinePresets[2].value))}>Reset</Button>
<output data-slot="timeline-detail-fixture-value" hidden>
{JSON.stringify(state.value)}
</output>
</div>
)
},
}
+64 -143
View File
@@ -1,33 +1,27 @@
import { For, Show, createMemo, createUniqueId } from "solid-js"
import { createStore } from "solid-js/store"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Select } from "@opencode-ai/ui/select"
import {
timelineCategories,
timelinePreset,
timelinePresets,
type TimelineCategory,
type TimelineDetail,
type TimelineExpansion,
type TimelinePlacement,
} from "@opencode-ai/session-ui/timeline/detail"
import { useLanguage } from "@/runtime/i18n/language"
import "./timeline-detail.css"
const presets = timelinePresets.toReversed()
const placements: TimelinePlacement[] = ["separate", "grouped", "hidden"]
const expansions: TimelineExpansion[] = ["collapsed", "expanded"]
export function TimelineDetailControl(props: { value: TimelineDetail; onChange: (value: TimelineDetail) => void }) {
const language = useLanguage()
const id = createUniqueId()
const [visiblePlacements, setVisiblePlacements] = createStore<
Partial<Record<TimelineCategory, Exclude<TimelinePlacement, "hidden">>>
>({})
const preset = createMemo(() => timelinePreset(props.value))
const position = () => {
const current = preset()
return current ? presets.indexOf(current) : 2
return current ? timelinePresets.indexOf(current) : 2
}
const label = () => {
const current = preset()
@@ -36,42 +30,34 @@ export function TimelineDetailControl(props: { value: TimelineDetail; onChange:
return (
<div data-component="timeline-detail-control">
<div data-slot="settings-row-copy">
<label data-slot="settings-row-title" for={`${id}-slider`}>
{language.t("settings.timeline.detail")}
</label>
<div id={`${id}-description`} data-slot="settings-row-description">
{language.t("settings.timeline.description")}
</div>
<div data-slot="timeline-detail-heading">
<label for={`${id}-slider`}>{language.t("settings.timeline.detail")}</label>
<span data-slot="timeline-detail-current" aria-live="polite">
{label()}
</span>
</div>
<p id={`${id}-description`} class="sr-only">
{language.t("settings.timeline.description")}
</p>
<div data-slot="timeline-detail-scale">
<div
data-slot="timeline-detail-track"
aria-hidden="true"
style={{ "--timeline-detail-progress": `${(position() / (presets.length - 1)) * 100}%` }}
>
<For each={presets}>
{(_, index) => <span style={{ "inset-inline-start": `${(index() / (presets.length - 1)) * 100}%` }} />}
</For>
<div data-slot="timeline-detail-track" aria-hidden="true">
<For each={timelinePresets}>{() => <span />}</For>
</div>
<input
id={`${id}-slider`}
data-action="settings-timeline-detail"
type="range"
min="0"
max={presets.length - 1}
max={timelinePresets.length - 1}
step="1"
value={position()}
aria-valuetext={label()}
aria-describedby={`${id}-description ${id}-preset-description`}
onInput={(event) => props.onChange({ ...presets[event.currentTarget.valueAsNumber].value })}
onInput={(event) => props.onChange({ ...timelinePresets[event.currentTarget.valueAsNumber].value })}
/>
</div>
<p id={`${id}-preset-description`} aria-live="polite">
<span data-slot="timeline-detail-summary">{language.t("settings.timeline.summary", { preset: label() })}</span>{" "}
{language.t(`settings.timeline.description.${preset()?.id ?? "custom"}`)}
</p>
<Collapsible variant="ghost" data-slot="timeline-detail-advanced" defaultOpen={!preset()}>
<p id={`${id}-preset-description`}>{language.t(`settings.timeline.description.${preset()?.id ?? "custom"}`)}</p>
<Collapsible variant="ghost" data-slot="timeline-detail-advanced">
<Collapsible.Trigger>
<span>{language.t("settings.timeline.advanced")}</span>
<Collapsible.Arrow />
@@ -82,122 +68,57 @@ export function TimelineDetailControl(props: { value: TimelineDetail; onChange:
role="group"
aria-label={language.t("settings.timeline.advanced.description")}
>
<p data-slot="timeline-detail-explainer">{language.t("settings.timeline.advanced.explainer")}</p>
<div data-slot="timeline-detail-columns">
<span aria-hidden="true" />
<span>{language.t("settings.timeline.group")}</span>
<span>{language.t("settings.timeline.collapse")}</span>
<span>{language.t("settings.timeline.activity")}</span>
<span id={`${id}-placement`}>{language.t("settings.timeline.placement.title")}</span>
<span id={`${id}-expansion`}>{language.t("settings.timeline.expansion.title")}</span>
</div>
<div data-slot="timeline-detail-list">
<For each={timelineCategories}>
{(category) => (
<div
data-slot="timeline-detail-category"
data-hidden={props.value[category].placement === "hidden" ? "" : undefined}
role="group"
aria-labelledby={`${id}-${category}`}
>
<div data-slot="timeline-detail-activity">
<Tooltip
placement="top"
value={language.t(
props.value[category].placement === "hidden"
? "settings.timeline.visibility.show"
: "settings.timeline.visibility.hide",
)}
>
<IconButton
id={`${id}-${category}-visibility`}
type="button"
variant="ghost-muted"
size="small"
data-action="timeline-detail-visibility"
aria-label={language.t("settings.timeline.visibility.label", {
activity: language.t(`settings.timeline.category.${category}`),
})}
aria-pressed={props.value[category].placement !== "hidden"}
onClick={() => {
const placement = props.value[category].placement
if (placement !== "hidden") setVisiblePlacements(category, placement)
props.onChange({
...props.value,
[category]: {
...props.value[category],
placement:
placement === "hidden" ? (visiblePlacements[category] ?? "grouped") : "hidden",
},
})
}}
icon={
<Icon
name={props.value[category].placement === "hidden" ? "outline-eye-slash" : "outline-eye"}
/>
<For each={timelineCategories}>
{(category) => (
<div data-slot="timeline-detail-category" role="group" aria-labelledby={`${id}-${category}`}>
<span id={`${id}-${category}`}>{language.t(`settings.timeline.category.${category}`)}</span>
<div data-slot="timeline-detail-placement">
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.placement.title")}
</span>
<Select
data-category={category}
data-field="placement"
aria-labelledby={`${id}-${category} ${id}-placement`}
options={placements}
current={props.value[category].placement}
label={(value) => language.t(`settings.timeline.placement.${value}`)}
onSelect={(placement) =>
placement &&
props.onChange({ ...props.value, [category]: { ...props.value[category], placement } })
}
/>
</div>
<div data-slot="timeline-detail-expansion">
{category === "shell" || category === "edit" || category === "thinking" ? (
<Show when={props.value[category].placement !== "hidden"}>
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.expansion.title")}
</span>
<Select
data-category={category}
data-field="details"
aria-labelledby={`${id}-${category} ${id}-expansion`}
options={expansions}
current={props.value[category].details}
label={(value) => language.t(`settings.timeline.expansion.${value}`)}
onSelect={(details) =>
details &&
props.onChange({ ...props.value, [category]: { ...props.value[category], details } })
}
/>
</Tooltip>
<label id={`${id}-${category}`} for={`${id}-${category}-visibility`}>
{language.t(`settings.timeline.category.${category}`)}
</label>
</div>
<div data-slot="timeline-detail-placement">
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.group")}
</span>
<Show
when={props.value[category].placement !== "hidden"}
fallback={<span data-slot="timeline-detail-unavailable" aria-hidden="true" />}
>
<Switch
data-category={category}
data-field="placement"
hideLabel
checked={props.value[category].placement === "grouped"}
onChange={(checked) =>
props.onChange({
...props.value,
[category]: { ...props.value[category], placement: checked ? "grouped" : "separate" },
})
}
>
{language.t("settings.timeline.grouped.label", {
activity: language.t(`settings.timeline.category.${category}`),
})}
</Switch>
</Show>
</div>
<div data-slot="timeline-detail-expansion">
{category === "shell" || category === "edit" || category === "thinking" ? (
<>
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.collapse")}
</span>
<Show
when={props.value[category].placement !== "hidden"}
fallback={<span data-slot="timeline-detail-unavailable" aria-hidden="true" />}
>
<Switch
data-category={category}
data-field="details"
hideLabel
checked={props.value[category].details === "collapsed"}
onChange={(checked) =>
props.onChange({
...props.value,
[category]: { ...props.value[category], details: checked ? "collapsed" : "expanded" },
})
}
>
{language.t("settings.timeline.collapsed.label", {
activity: language.t(`settings.timeline.category.${category}`),
})}
</Switch>
</Show>
</>
) : null}
</div>
) : null}
</div>
)}
</For>
</div>
</div>
)}
</For>
</div>
</Collapsible.Content>
</Collapsible>
@@ -1,8 +1,6 @@
import type { Component } from "solid-js"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Key } from "@solid-primitives/keyed"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
@@ -48,9 +46,7 @@ type Workspace = {
project: Project
}
export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProjectFilter: () => number }> = (
props,
) => {
export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
@@ -60,12 +56,6 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
const [store, setStore] = createStore({
project: "all",
transaction: undefined as "confirm" | "running" | undefined,
deleting: [] as string[],
removing: [] as string[],
})
createEffect(() => {
props.resetProjectFilter()
setStore("project", "all")
})
const projectQuery = useQuery(() => ({
@@ -121,7 +111,6 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
] as const,
queryFn: () => loadSessions(workspaceDirectories()),
enabled: serverSDK.connection.status() === "connected" && workspaceDirectories().length > 0,
placeholderData: (previous) => previous,
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(() => {
@@ -134,29 +123,14 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
)
})
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
const workspacesWithoutSessions = createMemo(() => {
if (sessionQuery.isPending || sessionQuery.isError) return []
return filtered().filter((workspace) => workspaceSessions(workspace).length === 0)
})
const sessionCount = (workspace: Workspace) => {
if (sessionQuery.isPending) return language.t("session.messages.loading")
if (sessionQuery.isError) return language.t("common.requestFailed")
const count = workspaceSessions(workspace).length
if (selectedProject() !== "all") return language.plural("settings.workspaces.sessions.filtered", count, { count })
const project = projectName(workspace.project)
const label = language.plural("settings.workspaces.sessions", count, {
return language.plural("settings.workspaces.sessions", count, {
count,
project,
project: projectName(workspace.project),
})
const start = label.lastIndexOf(project)
if (start < 0) return label
return (
<>
{label.slice(0, start)}
<span class="settings-workspaces-meta-project">{project}</span>
{label.slice(start + project.length)}
</>
)
}
const lastActive = (workspace: Workspace) => {
const updated = workspaceSessions(workspace)[0]?.time.updated
@@ -184,64 +158,54 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
}
const inspectionMessages = (result: WorkspaceDeleteInspection) => {
const messages = [
result.active ? language.t("settings.workspaces.delete.blocked.active") : undefined,
result.linked ? language.t("settings.workspaces.delete.blocked.linked") : undefined,
result.dirty ? language.t("workspace.status.dirty") : undefined,
].filter((message): message is string => message !== undefined)
return messages
return messages.length > 0 ? messages : [language.t("workspace.status.clean")]
}
const blocked = (result: WorkspaceDeleteInspection) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: result.active
? language.t("settings.workspaces.delete.blocked.active")
: inspectionMessages(result)[0],
description: inspectionMessages(result)[0],
})
}
const remove = async (workspace: Workspace, force = false, context = captureDeleteContext()) => {
const key = String(pathKey(workspace.directory))
setStore("deleting", (items) => [...items, key])
try {
const preflight = await inspect(workspace, context)
if (!force && (preflight.result.active || preflight.result.linked || preflight.result.dirty)) {
blocked(preflight.result)
return
}
const removed = await context.sdk.api.worktree
.remove({
location: { directory: workspace.project.worktree },
directory: workspace.directory,
force,
})
.then(() => true)
.catch((error) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
return false
})
if (!removed) return
setStore("removing", (items) => [...items, key])
await new Promise((resolve) => setTimeout(resolve, 150))
tabs.store.forEach((tab) => {
if (tab.type !== "draft" || tab.server !== context.server) return
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
if (!directoryMatches && !worktreeMatches) return
tabs.updateDraft(tab.draftID, {
directory: directoryMatches ? workspace.project.worktree : tab.directory,
worktree: undefined,
})
})
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
await projectQuery.refetch()
} finally {
setStore("deleting", (items) => items.filter((item) => item !== key))
setStore("removing", (items) => items.filter((item) => item !== key))
const preflight = await inspect(workspace, context)
if (preflight.result.active || (!force && (preflight.result.linked || preflight.result.dirty))) {
blocked(preflight.result)
return
}
const removed = await context.sdk.api.worktree
.remove({
location: { directory: workspace.project.worktree },
directory: workspace.directory,
force,
})
.then(() => true)
.catch((error) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
return false
})
if (!removed) return
tabs.store.forEach((tab) => {
if (tab.type !== "draft" || tab.server !== context.server) return
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
if (!directoryMatches && !worktreeMatches) return
tabs.updateDraft(tab.draftID, {
directory: directoryMatches ? workspace.project.worktree : tab.directory,
worktree: undefined,
})
})
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
await projectQuery.refetch()
}
let inspectionID = 0
@@ -289,30 +253,13 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
if (store.transaction) return
const context = captureDeleteContext()
const inventory = [...filtered()]
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteWorkspaces
title={language.t("settings.workspaces.deleteAll")}
confirmation={language.plural("settings.workspaces.deleteAll.confirm", inventory.length)}
warning={language.t("settings.workspaces.deleteAll.warning")}
onDelete={() => transact(() => removeAll(inventory, context))}
/>
),
releaseConfirmation,
)
}
const confirmDeleteWithoutSessions = () => {
if (store.transaction || workspacesWithoutSessions().length === 0) return
const context = captureDeleteContext()
const inventory = [...workspacesWithoutSessions()]
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteWorkspaces
title={language.t("settings.workspaces.deleteWithoutSessions")}
confirmation={language.plural("settings.workspaces.deleteWithoutSessions.confirm", inventory.length)}
warning={language.t("settings.workspaces.deleteWithoutSessions.warning")}
<DialogDeleteAllWorkspaces
count={inventory.length}
project={project}
onDelete={() => transact(() => removeAll(inventory, context))}
/>
),
@@ -324,45 +271,44 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
<>
<div class="settings-tab-header settings-workspaces-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.workspaces.description")}</span>
</div>
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
<InlineServerSelect />
</div>
</div>
<div class="settings-tab-body settings-workspaces">
<Show when={filtered().length > 0}>
<div class="settings-workspaces-toolbar">
<span class="settings-workspaces-count">
<div class="settings-workspaces-toolbar">
<span class="settings-workspaces-count">
<Show when={!projectQuery.isPending && !projectQuery.isError}>
{language.plural("settings.workspaces.count", filtered().length)}
</span>
<div class="settings-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger as={Button} size="small" variant="ghost-muted" class="max-w-48">
<span class="min-w-0 truncate">
{projectOptions().find((option) => option.id === selectedProject())?.label}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<For each={projectOptions()}>
{(option) => (
<Menu.Item onSelect={() => setStore("project", option.id)}>
<span class="min-w-0 flex-1 truncate">{option.label}</span>
<Show when={selectedProject() === option.id}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
</Show>
</span>
<div class="settings-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger class="flex h-6 max-w-48 items-center gap-1 rounded-sm px-2 text-13-medium hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed">
<span class="min-w-0 truncate">
{projectOptions().find((option) => option.id === selectedProject())?.label}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<For each={projectOptions()}>
{(option) => (
<Menu.Item onSelect={() => setStore("project", option.id)}>
<span class="min-w-0 flex-1 truncate">{option.label}</span>
<Show when={selectedProject() === option.id}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
<Show when={filtered().length > 0}>
<Menu placement="bottom-end" gutter={4}>
<Menu.Trigger
as={IconButton}
@@ -375,83 +321,78 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
/>
<Menu.Portal>
<Menu.Content>
<Show when={workspacesWithoutSessions().length > 0}>
<Menu.Item onSelect={confirmDeleteWithoutSessions}>
{language.t("settings.workspaces.deleteWithoutSessions")}
</Menu.Item>
<Menu.Separator />
</Show>
<Menu.Item onSelect={confirmDeleteAll}>
<span class="settings-workspaces-delete-all">{language.t("settings.workspaces.deleteAll")}</span>
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
</Show>
</div>
</Show>
</div>
<div class="settings-workspaces-inventory" data-empty={filtered().length === 0}>
<SettingsList>
<div class="settings-workspaces-empty-motion" data-visible={filtered().length === 0}>
<div class="settings-workspaces-inventory">
<Show
when={filtered().length > 0}
fallback={
<div class="settings-workspaces-empty">
<Show
when={!projectQuery.isPending && !projectQuery.isError}
fallback={language.t(projectQuery.isPending ? "common.loading" : "common.requestFailed")}
>
<span class="settings-workspaces-empty-title">{language.t("settings.workspaces.empty")}</span>
<span>{language.t("settings.workspaces.empty.description")}</span>
</Show>
{language.t(
projectQuery.isPending
? "common.loading"
: projectQuery.isError
? "common.requestFailed"
: "settings.workspaces.empty",
)}
</div>
</div>
<Key each={filtered()} by={(workspace) => `${workspace.project.id}:${pathKey(workspace.directory)}`}>
{(workspace) => {
const linked = () => workspaceSessions(workspace())
const key = () => String(pathKey(workspace().directory))
const deleting = () => store.deleting.includes(key())
return (
<div class="settings-workspaces-row-motion" data-removing={store.removing.includes(key())}>
}
>
<SettingsList>
<For each={filtered()}>
{(workspace) => {
const linked = () => workspaceSessions(workspace)
return (
<div class="settings-workspaces-row">
<div class="settings-workspaces-row-header">
<div class="settings-workspaces-copy">
<div class="settings-workspaces-main">
<WorkspacePath directory={workspace().directory} />
<Tooltip
value={workspace.directory}
placement="top-start"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
tabIndex={0}
dir="ltr"
aria-label={workspace.directory}
class="settings-workspaces-path"
>
{workspace.directory}
</span>
</Tooltip>
</div>
<span class="settings-workspaces-meta">{sessionCount(workspace())}</span>
<span class="settings-workspaces-meta">{sessionCount(workspace)}</span>
</div>
<div class="settings-workspaces-row-actions">
<Show
when={deleting()}
fallback={
<>
<Show when={lastActive(workspace())}>
{(value) => (
<Tooltip
value={language.t("settings.workspaces.lastActiveSession")}
placement="top-end"
>
<span tabIndex={0} class="settings-workspaces-active">
{value()}
</span>
</Tooltip>
)}
</Show>
<IconButton
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("workspace.delete.confirm", {
name: getFilename(workspace().directory),
})}
disabled={!!store.transaction}
icon={<Icon name="outline-trash" size="small" />}
onClick={() => confirmDelete(workspace())}
/>
</>
}
>
<span class="settings-workspaces-active">{language.t("workspace.lifecycle.deleting")}</span>
<Show when={lastActive(workspace)}>
{(value) => (
<Tooltip value={language.t("settings.workspaces.lastActiveSession")} placement="top-end">
<span tabIndex={0} class="settings-workspaces-active">
{value()}
</span>
</Tooltip>
)}
</Show>
<IconButton
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("workspace.delete.confirm", {
name: getFilename(workspace.directory),
})}
disabled={!!store.transaction}
icon={<Icon name="trash" size="small" />}
onClick={() => confirmDelete(workspace)}
/>
</div>
</div>
<Show when={linked().length > 0}>
@@ -460,7 +401,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
{(session) => (
<div class="settings-workspaces-session">
<span>{sessionLabel(session)}</span>
<Show when={linked().length > 1 ? sessionTime(session) : undefined}>
<Show when={sessionTime(session)}>
{(time) => <span class="settings-workspaces-session-time">{time()}</span>}
</Show>
</div>
@@ -469,48 +410,18 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
</div>
</Show>
</div>
</div>
)
}}
</Key>
</SettingsList>
)
}}
</For>
</SettingsList>
</Show>
</div>
</div>
</>
)
}
function WorkspacePath(props: { directory: string }) {
const [truncated, setTruncated] = createSignal(false)
const name = () => getFilename(props.directory)
return (
<Tooltip
value={props.directory}
placement="top-start"
disabled={!truncated()}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
ref={(element) => createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth))}
tabIndex={truncated() ? 0 : undefined}
dir="ltr"
aria-label={props.directory}
class="settings-workspaces-path"
>
<span>{props.directory.slice(0, -name().length)}</span>
<span class="settings-workspaces-path-name">{name()}</span>
</span>
</Tooltip>
)
}
function DialogDeleteWorkspaces(props: {
title: string
confirmation: string
warning: string
onDelete: () => Promise<void>
}) {
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
const dialog = useDialog()
const language = useLanguage()
const remove = () => {
@@ -523,12 +434,13 @@ function DialogDeleteWorkspaces(props: {
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={props.title}
title={language.t("settings.workspaces.deleteAll")}
description={
<div class="flex flex-col gap-2">
<div>{props.confirmation}</div>
<div>{props.warning}</div>
</div>
<>
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
<br />
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
</>
}
/>
</DialogHeader>
@@ -537,7 +449,7 @@ function DialogDeleteWorkspaces(props: {
{language.t("common.cancel")}
</Button>
<Button type="button" variant="danger" onClick={remove}>
{language.t("settings.workspaces.delete.button")}
{language.t("settings.workspaces.deleteAll")}
</Button>
</DialogFooter>
</Dialog>
@@ -560,7 +472,7 @@ function DialogDeleteWorkspace(props: {
staleTime: 0,
}))
const descriptions = () => {
if (status.isPending) return []
if (status.isPending) return [language.t("workspace.status.checking")]
if (status.isError) return [language.t("workspace.status.error")]
if (!status.data) return []
return props.inspectionMessages(status.data.result)
@@ -575,20 +487,18 @@ function DialogDeleteWorkspace(props: {
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
title={language.t("workspace.delete.title")}
description={
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<span class="text-11-regular text-v2-text-text-faint">
{language.t(status.isPending ? "workspace.status.checking" : "workspace.delete.location")}
</span>
<code class="block w-fit max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
{props.workspace.directory}
</code>
</div>
<div>{language.t("settings.workspaces.delete.warning")}</div>
<>
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
<br />
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
{props.workspace.directory}
</code>
<br />
{language.t("settings.workspaces.delete.warning")}
<For each={descriptions()}>{(description) => <div>{description}</div>}</For>
</div>
</>
}
/>
</DialogHeader>
@@ -596,7 +506,12 @@ function DialogDeleteWorkspace(props: {
<Button type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button type="button" variant="danger" disabled={status.isPending || status.isError} onClick={remove}>
<Button
type="button"
variant="danger"
disabled={status.isPending || status.isError || status.data?.result.active}
onClick={remove}
>
{language.t("workspace.delete.button")}
</Button>
</DialogFooter>
+1 -1
View File
@@ -19,7 +19,7 @@
display: flex;
flex-direction: column;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
border-radius: 16px 16px 0 0;
+4 -11
View File
@@ -47,9 +47,7 @@ export default function Layout(props: ParentProps) {
: platform.platform === "desktop" && platform.os === "windows"
? "1px"
: "8px",
"--shell-bottom-inset": bottomTitlebar()
? "8px"
: "max(0px, calc(8px - var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))))",
"--shell-bottom-inset": bottomTitlebar() ? "8px" : "max(0px, calc(8px - env(safe-area-inset-bottom, 0px)))",
}}
>
<Titlebar
@@ -69,7 +67,7 @@ export default function Layout(props: ParentProps) {
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep pe-0.5 ps-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
style={{
width: `${state.tabsWidth}px`,
"padding-bottom": "max(10px, var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)))",
"padding-bottom": "max(10px, env(safe-area-inset-bottom, 0px))",
}}
>
<ResizeHandle
@@ -87,13 +85,8 @@ export default function Layout(props: ParentProps) {
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-content"
style={{
"padding-top": bottomTitlebar() ? "env(safe-area-inset-top, 0px)" : "0px",
"padding-bottom":
bottomTitlebar() || settings.active()
? "0px"
: "var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))",
"--settings-bottom-inset": bottomTitlebar()
? "40px"
: "var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))",
"padding-bottom": bottomTitlebar() || settings.active() ? "0px" : "env(safe-area-inset-bottom, 0px)",
"--settings-bottom-inset": bottomTitlebar() ? "40px" : "env(safe-area-inset-bottom, 0px)",
"--settings-top-inset": mobile() && !bottomTitlebar() ? "0px" : "var(--shell-top-inset, 8px)",
}}
>
+3 -3
View File
@@ -155,11 +155,11 @@ export function Titlebar(props: {
height:
platform.platform === "web"
? bottom()
? "calc(28px + max(8px, var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))))"
? "calc(28px + max(8px, env(safe-area-inset-bottom, 0px)))"
: "calc(28px + max(8px, env(safe-area-inset-top, 0px)))"
: undefined,
"padding-top": bottom() ? "0px" : "env(safe-area-inset-top, 0px)",
"padding-bottom": bottom() ? "var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))" : "0px",
"padding-bottom": bottom() ? "env(safe-area-inset-bottom, 0px)" : "0px",
"min-height": minHeight(),
// Keep native macOS traffic lights clear even when the desktop window is narrow.
"padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
@@ -440,7 +440,7 @@ export function Titlebar(props: {
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pe-3"
classList={{
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows(),
"pb-[max(0px,calc(8px-var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))))]": bottom(),
"pb-[max(0px,calc(8px-env(safe-area-inset-bottom,0px)))]": bottom(),
"pl-4": macTrafficLights(),
// Center the 20px app icon over the sidebar's 16px icon column.
"ps-3.5": windows(),
@@ -4,7 +4,6 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
import { ServerScope } from "@/runtime/server/scope"
import { createComposerState, type ComposerStore } from "@/composer/state"
import { createComposerEditorActions } from "@/composer/editor/actions"
import { flushPersisted } from "@/runtime/persistence/persist"
function setup(read: () => string | null | Promise<string | null> = () => null) {
return createRoot((dispose) => {
@@ -28,72 +27,83 @@ function setup(read: () => string | null | Promise<string | null> = () => null)
})
}
test("composer-write-batch: a burst of edits persists once with prompt, cursor and retry together", async () => {
test("composer-write-batch: typing persists prompt, cursor and retry together before returning", async () => {
const value = setup()
try {
await value.state.ready.promise
value.state.context.add({ type: "file", path: "src/queue.ts", preview: "await queue.flush()" })
value.state.retry.set({ id: SessionMessage.ID.create(), agent: "build", providerID: "test", modelID: "test" })
flushPersisted()
value.writes.length = 0
value.editor.setPrompt([{ type: "text", content: "keep ordering", start: 0, end: 13 }], 13)
value.editor.setCursor(12)
expect(value.writes).toHaveLength(0)
flushPersisted()
expect(value.writes).toHaveLength(1)
expect(value.writes[0]).toMatchObject({
prompt: [{ type: "text", content: "keep ordering", start: 0, end: 13 }],
cursor: 12,
cursor: 13,
context: { items: [{ path: "src/queue.ts", preview: "await queue.flush()" }] },
})
expect(value.writes[0].retry).toBeUndefined()
} finally {
value.dispose()
}
})
test("composer-write-batch: a save with no serialized change writes nothing", async () => {
const value = setup()
try {
await value.state.ready.promise
value.state.set([{ type: "text", content: "previous", start: 0, end: 8 }], 5)
value.state.mode.set("shell")
flushPersisted()
value.writes.length = 0
value.editor.setCursor(5)
value.state.mode.set("shell")
flushPersisted()
expect(value.writes).toHaveLength(0)
value.state.reset()
flushPersisted()
value.editor.setCursor(13)
expect(value.writes).toHaveLength(1)
expect(value.writes[0]).toMatchObject({ prompt: [{ content: "" }], cursor: 0, mode: "shell" })
value.editor.setCursor(12)
expect(value.writes.map((write) => write.cursor)).toEqual([13, 12])
value.editor.setCursor(12)
expect(value.writes).toHaveLength(2)
} finally {
value.dispose()
}
})
test("composer-write-batch: state replacement snapshots the value at save time", async () => {
test("composer-write-batch: state replacement preserves an omitted cursor and resets in one write", async () => {
const value = setup()
try {
await value.state.ready.promise
value.state.set([{ type: "text", content: "previous", start: 0, end: 8 }], 5)
flushPersisted()
value.state.mode.set("shell")
value.state.retry.set({ id: SessionMessage.ID.create(), agent: "build", providerID: "test", modelID: "test" })
value.writes.length = 0
const prompt = [{ type: "text" as const, content: "next", start: 0, end: 4 }]
value.state.set(prompt)
prompt[0].content = "changed outside the store"
expect(value.state.current()[0]).toMatchObject({ content: "next" })
flushPersisted()
expect(value.writes).toHaveLength(1)
expect(value.writes[0].prompt).toEqual([{ type: "text", content: "next", start: 0, end: 4 }])
expect(value.writes[0].cursor).toBe(5)
expect(value.writes[0].retry).toBeUndefined()
value.state.reset()
expect(value.writes).toHaveLength(2)
expect(value.writes[1]).toMatchObject({ prompt: [{ content: "" }], cursor: 0, mode: "shell" })
value.state.mode.set("normal")
value.state.mode.set("normal")
expect(value.writes).toHaveLength(3)
expect(value.writes[2].mode).toBe("normal")
} finally {
value.dispose()
}
})
test("composer-write-batch: the last edit in a window wins and attachments are retained", async () => {
test("composer-write-batch: unchanged mode still clears a retry and context writes remain ordered", async () => {
const value = setup()
try {
await value.state.ready.promise
value.state.mode.set("normal")
value.state.retry.set({ id: SessionMessage.ID.create(), agent: "build", providerID: "test", modelID: "test" })
value.writes.length = 0
value.editor.setMode("normal")
expect(value.writes).toHaveLength(1)
expect(value.writes[0].retry).toBeUndefined()
value.state.context.add({ type: "file", path: "first.ts" })
value.state.context.add({ type: "file", path: "second.ts" })
value.state.context.remove(value.state.context.items()[0].key)
expect(value.writes.slice(1).map((write) => write.context.items.map((item) => item.path))).toEqual([
["first.ts"],
["first.ts", "second.ts"],
["second.ts"],
])
} finally {
value.dispose()
}
})
test("composer-write-batch: text replacement and insertion each persist once and retain attachments", async () => {
const value = setup()
try {
await value.state.ready.promise
@@ -110,14 +120,12 @@ test("composer-write-batch: the last edit in a window wins and attachments are r
],
3,
)
flushPersisted()
value.writes.length = 0
value.editor.setText("new")
value.editor.addText(" notes")
flushPersisted()
expect(value.writes).toHaveLength(1)
expect(value.writes[0].cursor).toBe(9)
expect(value.writes[0].prompt).toEqual([
expect(value.writes).toHaveLength(2)
expect(value.writes.map((write) => write.cursor)).toEqual([3, 9])
expect(value.writes[1].prompt).toEqual([
{ type: "text", content: "new notes", start: 0, end: 9 },
{
type: "image",
@@ -137,6 +145,7 @@ test("composer-write-batch: an edit still wins over a pending persisted read", a
const value = setup(() => loading.promise)
try {
value.editor.setPrompt([{ type: "text", content: "new", start: 0, end: 3 }], 3)
expect(value.writes).toHaveLength(1)
loading.resolve(
JSON.stringify({
prompt: [{ type: "text", content: "old", start: 0, end: 3 }],
@@ -147,15 +156,13 @@ test("composer-write-batch: an edit still wins over a pending persisted read", a
await value.state.ready.promise
expect(value.state.current()).toEqual([{ type: "text", content: "new", start: 0, end: 3 }])
expect(value.state.cursor()).toBe(3)
flushPersisted()
expect(value.writes).toHaveLength(1)
expect(value.writes[0].prompt).toEqual([{ type: "text", content: "new", start: 0, end: 3 }])
} finally {
value.dispose()
}
})
test("composer-write-batch: observers see every edit before anything is persisted", async () => {
test("composer-write-batch: persistence still precedes reactive observers", async () => {
const value = setup()
try {
await value.state.ready.promise
@@ -170,22 +177,10 @@ test("composer-write-batch: observers see every edit before anything is persiste
value.editor.addText(" and insert")
value.state.set([{ type: "text", content: "restore", start: 0, end: 7 }], 7)
value.state.reset()
expect(observed).toEqual([0, 0, 0, 0, 0, 0])
flushPersisted()
expect(value.writes).toHaveLength(1)
expect(observed).toEqual([0, 1, 2, 3, 4, 5])
dispose()
})
} finally {
value.dispose()
}
})
test("composer-write-batch: disposing the owner saves pending edits", async () => {
const value = setup()
await value.state.ready.promise
value.editor.setPrompt([{ type: "text", content: "unsaved", start: 0, end: 7 }], 7)
expect(value.writes).toHaveLength(0)
value.dispose()
expect(value.writes).toHaveLength(1)
expect(value.writes[0].prompt).toEqual([{ type: "text", content: "unsaved", start: 0, end: 7 }])
})
@@ -9,10 +9,7 @@ function fixture(id: string, getBlob: () => Promise<Blob | null>) {
])
const store = createDraftStore({
get: async (key) => documents.get(key) ?? null,
set: async (key, value) => {
documents.set(key, value)
return []
},
set: async (key, value) => void documents.set(key, value),
remove: async (key) => void documents.delete(key),
putBlob: async () => id,
getBlob,
@@ -116,7 +113,7 @@ test("keeps different blob IDs independent", async () => {
const reads: string[] = []
const store = createDraftStore({
get: async () => JSON.stringify(["history-cache-first", "history-cache-second"].map((id) => ({ blob: { id } }))),
set: async () => [],
set: async () => {},
remove: async () => {},
putBlob: async () => "unused",
getBlob: async (id) => {
@@ -1,7 +1,6 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { ModelSelectionSchema } from "@/providers/models/selection"
import { flushPersisted } from "@/runtime/persistence/persist"
import { persisted } from "@/runtime/persistence/storage"
test("persisted model selection hydrates, updates and serializes the schema shape", () => {
@@ -26,7 +25,6 @@ test("persisted model selection hydrates, updates and serializes the schema shap
expect(state.session.session1?.agent).toBe("plan")
setState("session", "session1", { agent: "build", variant: null })
expect(state.session.session1?.agent).toBe("build")
flushPersisted()
expect(JSON.parse(localStorage.getItem(key) ?? "null")).toEqual({
session: { session1: { agent: "build", variant: null } },
})
@@ -6,7 +6,6 @@ import type { Platform } from "@/runtime/platform/platform"
import { createComposerReady, createComposerState } from "@/composer/state"
import { ServerScope } from "@/runtime/server/scope"
import { createDraftStore } from "@/runtime/persistence/drafts"
import { flushPersisted } from "@/runtime/persistence/persist"
import { Persist, persisted } from "@/runtime/persistence/storage"
let read: ((value: string | null) => void) | undefined
@@ -40,7 +39,7 @@ describe("prompt persistence", () => {
async (raw) => {
const store = createDraftStore({
get: async () => raw,
set: async () => [],
set: async () => undefined,
remove: async () => undefined,
putBlob: async () => "unused",
getBlob: async () => null,
@@ -68,10 +67,7 @@ describe("prompt persistence", () => {
const blobs = new Map<string, Blob>()
const store = createDraftStore({
get: async (key) => documents.get(key) ?? null,
set: async (key, value) => {
documents.set(key, value)
return []
},
set: async (key, value) => void documents.set(key, value),
remove: async (key) => void documents.delete(key),
putBlob: async (blob) => {
blobs.set("composer-image", blob)
@@ -113,7 +109,6 @@ describe("prompt persistence", () => {
},
])
root.session.set([{ type: "text", content: "hello", start: 0, end: 5 }, ...root.session.current()])
flushPersisted()
await Bun.sleep(0)
expect(documents.get(key)).toContain("hello")
expect(documents.get(key)).toContain('"blob":{"id":"composer-image"}')
@@ -173,10 +168,7 @@ describe("prompt persistence", () => {
const documents = new Map<string, string>()
const store = createDraftStore({
get: async (key) => documents.get(key) ?? null,
set: async (key, value) => {
documents.set(key, value)
return []
},
set: async (key, value) => void documents.set(key, value),
remove: async (key) => void documents.delete(key),
putBlob: async () => "blob",
getBlob: async () => null,
@@ -207,10 +199,7 @@ describe("prompt persistence", () => {
const documents = new Map<string, string>()
const store = createDraftStore({
get: async (key) => documents.get(key) ?? null,
set: async (key, value) => {
documents.set(key, value)
return []
},
set: async (key, value) => void documents.set(key, value),
remove: async (key) => void documents.delete(key),
putBlob: async () => "blob",
getBlob: async () => null,
@@ -242,10 +231,7 @@ test("moves image data URLs into blobs and hydrates object URLs", async () => {
const blobs = new Map<string, Blob>()
const store = createDraftStore({
get: async (key) => documents.get(key) ?? null,
set: async (key, value) => {
documents.set(key, value)
return []
},
set: async (key, value) => void documents.set(key, value),
remove: async (key) => void documents.delete(key),
putBlob: async (blob) => {
const id = String(blob.size)
@@ -267,10 +253,7 @@ test("does not let delayed blob migration overwrite a newer draft", async () =>
const migration = Promise.withResolvers<void>()
const store = createDraftStore({
get: async () => null,
set: async (key, value) => {
documents.set(key, value)
return []
},
set: async (key, value) => void documents.set(key, value),
remove: async () => undefined,
putBlob: async () => {
await migration.promise
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import { Schema, SchemaGetter } from "effect"
import { createComputed, createRoot } from "solid-js"
import type { Platform } from "@/runtime/platform/platform"
import { flushPersisted } from "@/runtime/persistence/persist"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { TabStorage } from "@/shell/tabs/schema"
@@ -58,7 +57,6 @@ describe("schema-backed persistence", () => {
expect(state.key).toBe("session-tab")
setState("key", undefined)
expect(state.key).toBeUndefined()
flushPersisted()
expect(localStorage.getItem(key)).toBe("{}")
} finally {
dispose()
@@ -76,7 +74,6 @@ describe("schema-backed persistence", () => {
expect(state).toEqual({ enabled: true, label: "saved" })
expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ enabled: true, label: "saved" })
setState("enabled", false)
flushPersisted()
expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ enabled: false, label: "saved" })
dispose()
})
@@ -128,7 +125,6 @@ describe("schema-backed persistence", () => {
label: "desktop",
})
root.state[1]("label", "changed")
flushPersisted()
expect(JSON.parse(storage.values.get("opencode.global.dat:schema-desktop")!)).toEqual({
enabled: true,
label: "changed",
+1 -5
View File
@@ -605,10 +605,7 @@ export type SessionLogOutput =
readonly type: "session.execution.interrupted"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly reason: "user" | "shutdown" | "superseded" | "inactivity"
}
readonly data: { readonly sessionID: Session.ID; readonly reason: "user" | "shutdown" | "superseded" }
}
| {
readonly id: Event.ID
@@ -1429,7 +1426,6 @@ export type ProjectListOperation<E = never> = () => Effect.Effect<ProjectListOut
export type ProjectUpdateInput = {
readonly projectID: Project.ID
readonly canonical?: AbsolutePath | undefined
readonly name?: string | undefined
readonly icon?: Project.Icon | undefined
readonly commands?: Project.Commands | undefined
@@ -1018,7 +1018,7 @@ const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: Proj
preserveEffect<ProjectUpdateOutput>()(
raw["project.update"]({
params: { projectID: input["projectID"] },
payload: { canonical: input["canonical"], name: input["name"], icon: input["icon"], commands: input["commands"] },
payload: { name: input["name"], icon: input["icon"], commands: input["commands"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1377,12 +1377,7 @@ export function make(options: ClientOptions) {
{
method: "PATCH",
path: `/api/project/${encodeURIComponent(input.projectID)}`,
body: {
canonical: input["canonical"],
name: input["name"],
icon: input["icon"],
commands: input["commands"],
},
body: { name: input["name"], icon: input["icon"], commands: input["commands"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
+1 -10
View File
@@ -710,7 +710,7 @@ export type SessionExecutionInterrupted = {
type: "session.execution.interrupted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; reason: "user" | "shutdown" | "superseded" | "inactivity" }
data: { sessionID: string; reason: "user" | "shutdown" | "superseded" }
}
export type SessionInstructionsUpdated = {
@@ -4672,26 +4672,17 @@ export type ProjectListOutput = Array<Project>
export type ProjectUpdateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly canonical?: {
readonly canonical?: string
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["canonical"]
readonly name?: {
readonly canonical?: string
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["name"]
readonly icon?: {
readonly canonical?: string
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["icon"]
readonly commands?: {
readonly canonical?: string
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
+11 -83
View File
@@ -1,14 +1,13 @@
export * as Credential from "./credential.js"
import { asc, desc, eq } from "drizzle-orm"
import { Cause, Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Database } from "./database/database.js"
import { Bus } from "./bus.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { CredentialTable } from "./credential/sql.js"
import { ErrorSummary } from "./util/error-summary.js"
export const ID = Credential.ID
export type ID = Credential.ID
@@ -124,21 +123,7 @@ const layer = Layer.effect(
.run()
}),
)
.pipe(
Effect.onError((cause) =>
Effect.logError("credential create failed", {
credentialID: credential.id,
integrationID: credential.integrationID,
errors: ErrorSummary.from(Cause.squash(cause)),
}),
),
Effect.orDie,
)
yield* Effect.logInfo("credential created", {
credentialID: credential.id,
integrationID: credential.integrationID,
type: credential.value.type,
})
.pipe(Effect.orDie)
yield* bus.publish(Event.Updated, {}, { global: true })
yield* bus.publish(
Event.Switched,
@@ -169,19 +154,8 @@ const layer = Layer.effect(
return credential.integration_id
}),
)
.pipe(
Effect.onError((cause) =>
Effect.logError("credential activate failed", {
credentialID: id,
errors: ErrorSummary.from(Cause.squash(cause)),
}),
),
Effect.orDie,
)
if (integrationID) {
yield* Effect.logInfo("credential activated", { integrationID, credentialID: id })
yield* bus.publish(Event.Switched, { integrationID, credentialID: id }, { global: true })
}
.pipe(Effect.orDie)
if (integrationID) yield* bus.publish(Event.Switched, { integrationID, credentialID: id }, { global: true })
}),
update: Effect.fn("Credential.update")(function* (id, updates) {
if (updates.label === undefined && updates.value === undefined) return
@@ -190,46 +164,15 @@ const layer = Layer.effect(
.from(CredentialTable)
.where(eq(CredentialTable.id, id))
.get()
.pipe(
Effect.onError((cause) =>
Effect.logError("credential update lookup failed", {
credentialID: id,
errors: ErrorSummary.from(Cause.squash(cause)),
}),
),
Effect.orDie,
)
if (!credential?.integrationID) {
yield* Effect.logWarning("credential update skipped", { credentialID: id, reason: "credential_missing" })
return
}
.pipe(Effect.orDie)
if (!credential?.integrationID) return
if (updates.label === credential.label && updates.value === undefined) return
const updated = yield* db
yield* db
.update(CredentialTable)
.set({ label: updates.label, value: updates.value })
.where(eq(CredentialTable.id, id))
.returning({ id: CredentialTable.id })
.get()
.pipe(
Effect.onError((cause) =>
Effect.logError("credential update failed", {
credentialID: id,
integrationID: credential.integrationID,
errors: ErrorSummary.from(Cause.squash(cause)),
}),
),
Effect.orDie,
)
if (!updated) {
yield* Effect.logWarning("credential update skipped", { credentialID: id, reason: "credential_removed" })
return
}
yield* Effect.logInfo("credential updated", {
credentialID: id,
integrationID: credential.integrationID,
valueChanged: updates.value !== undefined,
labelChanged: updates.label !== undefined && updates.label !== credential.label,
})
.run()
.pipe(Effect.orDie)
if (updates.label !== undefined && updates.label !== credential.label)
yield* bus.publish(Event.Updated, {}, { global: true })
}),
@@ -248,8 +191,7 @@ const layer = Layer.effect(
.get()
: undefined
yield* tx.delete(CredentialTable).where(eq(CredentialTable.id, id)).run()
if (!credential.integration_id || active?.id !== id)
return { switched: false as const, integrationID: credential.integration_id }
if (!credential.integration_id || active?.id !== id) return { switched: false as const }
const replacement = yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
@@ -275,22 +217,8 @@ const layer = Layer.effect(
}
}),
)
.pipe(
Effect.onError((cause) =>
Effect.logError("credential remove failed", {
credentialID: id,
errors: ErrorSummary.from(Cause.squash(cause)),
}),
),
Effect.orDie,
)
.pipe(Effect.orDie)
if (!removed) return
yield* Effect.logInfo("credential removed", {
credentialID: id,
integrationID: removed.integrationID,
active: removed.switched,
...(removed.switched ? { replacementID: removed.credentialID } : {}),
})
yield* bus.publish(Event.Updated, {}, { global: true })
if (removed.switched)
yield* bus.publish(
+13 -29
View File
@@ -24,7 +24,7 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
const sessions = yield* SessionStore.Service
const timeToLive = Duration.toMillis(options.timeToLive ?? "60 minutes")
const entries = new Map<string, { readonly ref: Location.Ref; expiresAt: number }>()
const key = (ref: Location.Ref) => `${LocationServiceMap.canonical(ref).directory}\0${ref.workspaceID ?? ""}`
const key = (ref: Location.Ref) => `${ref.directory}\0${ref.workspaceID ?? ""}`
const touch = (ref: Location.Ref) =>
Effect.sync(() => {
entries.set(key(ref), { ref, expiresAt: clock.currentTimeMillisUnsafe() + timeToLive })
@@ -51,36 +51,20 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
const expired = Array.from(entries.values()).filter((entry) => entry.expiresAt <= now)
if (expired.length === 0) return
const active = yield* Effect.forEach(yield* execution.active, (sessionID) => sessions.get(sessionID))
const occupied = new Set(active.flatMap((session) => (session ? [key(session.location)] : [])))
yield* Effect.forEach(
expired,
(entry) =>
Effect.gen(function* () {
const owners = active.flatMap((session) =>
session && key(session.location) === key(entry.ref) ? [session] : [],
)
// Invalidation only detaches the cache entry; borrowers retain the old
// graph. Stop its executions and settle tool cleanup before detaching it.
yield* Effect.forEach(
owners,
(session) => execution.interrupt(session.id, { reason: "inactivity", awaitSettlement: true }),
{
discard: true,
concurrency: "unbounded",
},
)
const remaining = yield* Effect.forEach(yield* execution.active, (sessionID) => sessions.get(sessionID))
// New work admitted during cleanup may now own the cached graph.
if (remaining.some((session) => session && key(session.location) === key(entry.ref))) {
yield* touch(entry.ref)
return
}
entries.delete(key(entry.ref))
yield* Effect.logInfo("location services evicted", {
directory: entry.ref.directory,
workspaceID: entry.ref.workspaceID,
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
}),
{ discard: true, concurrency: "unbounded" },
(entry) => {
// Waiting for a question or a long-running tool emits no activity.
// Invalidating a borrowed graph would strand it behind a new cache entry.
if (occupied.has(key(entry.ref))) return touch(entry.ref)
entries.delete(key(entry.ref))
return Effect.logInfo("location services evicted", {
directory: entry.ref.directory,
workspaceID: entry.ref.workspaceID,
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
},
{ discard: true },
)
}).pipe(Effect.forever, Effect.forkScoped)
-3
View File
@@ -231,8 +231,6 @@ export const connect = Effect.fnUntraced(function* (
}
if (!URL.canParse(config.url))
return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
const fetch = yield* McpOAuth.loggedFetch({ server, directory })
// Prefer raw tools for our Code Mode without changing the configured URL used for OAuth identity.
const url = new URL(config.url)
const addedCodemode = config.codemode !== false && !url.searchParams.has("codemode")
@@ -242,7 +240,6 @@ export const connect = Effect.fnUntraced(function* (
new StreamableHTTPClientTransport(url, {
requestInit: config.headers ? { headers: config.headers } : undefined,
authProvider,
fetch,
}),
)
+8 -49
View File
@@ -259,41 +259,27 @@ export const layer = (options?: Options) =>
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
const remote = entry.config
const oauth = remote.oauth || undefined
const run = Effect.runPromiseWith(yield* Effect.context())
const base = {
redirectUrl: oauth?.redirect_uri ?? "http://127.0.0.1/callback",
scope: oauth?.scope,
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
// No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
onRedirect: () => run(Effect.logInfo("mcp oauth authorization required")),
onRedirect: () => {},
}
const found = (yield* credentials.list(entry.integrationID)).at(-1)
if (!found || found.value.type !== "oauth") {
if (!found || found.value.type !== "oauth")
// No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
// a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
// unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
yield* Effect.logInfo("mcp oauth credential unavailable", {
integrationID: entry.integrationID,
reason: found ? "not_oauth" : "missing",
})
return McpOAuth.provider({ ...base, store: McpOAuth.memoryStore() })
}
const credentialID = found.id
const methodID = found.value.methodID
const fields = { credentialID, integrationID: entry.integrationID }
yield* Effect.logInfo("mcp oauth credential loaded", {
...fields,
hasRefreshToken: Boolean(found.value.refresh),
hasClientInformation: Boolean(McpOAuth.clientFromCredential(found.value)),
expiresAt: found.value.expires,
expired: found.value.expires !== 0 && found.value.expires <= Date.now(),
})
// Tracks the refresh token this provider last presented, so invalidate can tell whether the SDK
// rejected the currently-stored credential or a snapshot another connection has already rotated past.
let presented = found.value.refresh
const readOAuthCredential = async () => {
const stored = await run(credentials.get(credentialID))
const stored = await Effect.runPromise(credentials.get(credentialID))
return stored?.value.type === "oauth" ? stored.value : undefined
}
return McpOAuth.provider({
@@ -304,25 +290,10 @@ export const layer = (options?: Options) =>
// strand every connection in needs_auth until a manual re-auth. Credential deletion notifies all locations;
// reconnects remain serialized by the server lock.
invalidate: async (scope) => {
if (scope === "verifier" || scope === "discovery") {
await run(
Effect.logDebug("mcp oauth invalidation skipped", { ...fields, scope, reason: "not_credentials" }),
)
return
}
if (scope === "verifier" || scope === "discovery") return
const oauth = await readOAuthCredential()
if (!oauth || oauth.refresh !== presented) {
await run(
Effect.logInfo("mcp oauth invalidation skipped", {
...fields,
scope,
reason: oauth ? "token_rotated" : "credential_missing",
}),
)
return
}
await run(Effect.logWarning("mcp oauth credential invalidation requested", { ...fields, scope }))
await run(credentials.remove(credentialID))
if (!oauth || oauth.refresh !== presented) return
await Effect.runPromise(credentials.remove(credentialID))
},
// Always read the latest stored tokens instead of caching at connect time: with refresh-token rotation,
// a cached snapshot goes stale the moment another connection refreshes, and re-presenting the consumed
@@ -343,16 +314,7 @@ export const layer = (options?: Options) =>
client: previous ? McpOAuth.clientFromCredential(previous) : undefined,
})
presented = value.refresh
await run(
Effect.logInfo("mcp oauth tokens received", {
...fields,
credentialPresent: Boolean(previous),
refreshRotated: Boolean(previous && previous.refresh !== value.refresh),
hasRefreshToken: Boolean(value.refresh),
expiresAt: value.expires,
}),
)
await run(credentials.update(credentialID, { value }))
await Effect.runPromise(credentials.update(credentialID, { value }))
},
clientInformation: async () => {
const oauth = await readOAuthCredential()
@@ -598,10 +560,7 @@ export const layer = (options?: Options) =>
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
yield* bus.publish(McpEvent.StatusChanged, { server: name })
}).pipe(
Effect.ensuring(entry.startup.open),
Effect.annotateLogs({ server: name, directory: location.directory, connectionID: crypto.randomUUID() }),
)
}).pipe(Effect.ensuring(entry.startup.open))
const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
const scope = entry.scope
+9 -90
View File
@@ -1,63 +1,12 @@
export * as McpOAuth from "./oauth.js"
import { auth, parseErrorResponse, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"
import { Cause, Deferred, Effect } from "effect"
import { Deferred, Effect } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { OauthCallbackPage } from "../oauth/page.js"
import type { Integration } from "../integration.js"
import { ErrorSummary } from "../util/error-summary.js"
/** Observe OAuth failures before the SDK handles them by invalidating credentials or redirecting. */
export const loggedFetch = (fields: { readonly server: string; readonly directory?: string }) =>
Effect.gen(function* () {
const run = Effect.runPromiseWith(yield* Effect.context())
const request: FetchLike = (url, init) => {
const grant = init?.body instanceof URLSearchParams ? init.body.get("grant_type") : undefined
const operation = grant === "refresh_token" ? "refresh" : grant === "authorization_code" ? "exchange" : undefined
const started = Date.now()
return run(
Effect.gen(function* () {
if (operation) yield* Effect.logInfo("mcp oauth request started")
const response = yield* Effect.tryPromise({ try: () => fetch(url, init), catch: (error) => error })
const result = { status: response.status, durationMs: Date.now() - started }
if (operation && !response.ok) {
// Only retain the SDK's standard error code. Descriptions and raw bodies can echo credentials.
const error = yield* Effect.tryPromise(async () => parseErrorResponse(await response.clone().text())).pipe(
Effect.map((error) => error.errorCode),
Effect.orElseSucceed(() => "unreadable_response"),
)
yield* Effect.logWarning("mcp oauth request rejected", { ...result, error })
}
if (operation && response.ok) {
yield* Effect.logInfo("mcp oauth request succeeded", result)
}
if (!operation && (response.status === 401 || response.status === 403)) {
yield* Effect.logWarning("mcp http authentication rejected", result)
}
return response
}).pipe(
Effect.onError((cause) => {
if (init?.signal?.aborted) return Effect.logDebug("mcp http request aborted")
return Effect.logWarning("mcp http request failed", {
errors: ErrorSummary.from(Cause.squash(cause)),
durationMs: Date.now() - started,
})
}),
Effect.annotateLogs({
...fields,
requestID: crypto.randomUUID(),
origin: new URL(url).origin,
method: init?.method ?? "GET",
...(operation ? { operation } : {}),
}),
),
)
}
return request
})
/** Persists the OAuth artifacts for one MCP server session: DCR client info, PKCE verifier, and tokens. */
export interface Store {
@@ -196,12 +145,6 @@ export const authorize = (input: {
readonly methodID: Integration.MethodID
}) =>
Effect.gen(function* () {
const fields = { server: input.name, methodID: input.methodID, oauthAttemptID: crypto.randomUUID() }
const context = yield* Effect.context()
const run = Effect.runPromiseWith(context)
const runFork = Effect.runForkWith(context)
const fetchFn = yield* loggedFetch({ server: input.name }).pipe(Effect.annotateLogs(fields))
yield* Effect.logInfo("mcp oauth authorization started", fields)
const oauth = input.config.oauth || undefined
const store = memoryStore()
const code = yield* Deferred.make<string, Error>()
@@ -217,20 +160,19 @@ export const authorize = (input: {
response.writeHead(404).end("Not found")
return
}
const fail = (reason: string, failure: string) => {
runFork(Effect.logWarning("mcp oauth callback rejected", { ...fields, reason: failure }))
const fail = (reason: string) => {
Effect.runFork(Deferred.fail(code, new Error(reason)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(reason, { provider: input.name }))
}
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
if (error) return fail(error, "authorization_error")
if (error) return fail(error)
// Reject a redirect whose state does not match what we issued: this is the CSRF defense the
// state parameter exists for, so an attacker can't inject their own authorization code.
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch", "state_mismatch")
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch")
const value = url.searchParams.get("code")
if (!value) return fail("Missing authorization code", "missing_code")
if (!value) return fail("Missing authorization code")
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: input.name }))
})
@@ -260,7 +202,6 @@ export const authorize = (input: {
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
onRedirect: (url) => {
authorizationUrl = url
return run(Effect.logInfo("mcp oauth awaiting authorization", fields))
},
store,
})
@@ -269,16 +210,11 @@ export const authorize = (input: {
const tokens = yield* Effect.promise(() => store.tokens())
if (!tokens) return yield* Effect.fail(new Error(`MCP server "${input.name}" did not return OAuth tokens`))
const client = yield* Effect.promise(() => store.clientInformation())
yield* Effect.logInfo("mcp oauth authorization completed", {
...fields,
hasRefreshToken: Boolean(tokens.refresh_token),
expiresIn: tokens.expires_in,
})
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
})
yield* Effect.tryPromise({
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope, fetchFn }),
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
@@ -293,28 +229,11 @@ export const authorize = (input: {
Effect.flatMap((value) =>
Effect.tryPromise({
try: () =>
auth(oauthProvider, {
serverUrl: input.config.url,
authorizationCode: value,
scope: oauth?.scope,
fetchFn,
}),
auth(oauthProvider, { serverUrl: input.config.url, authorizationCode: value, scope: oauth?.scope }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}),
),
Effect.flatMap(() => finalize),
Effect.onError((cause) =>
Effect.logWarning("mcp oauth authorization failed", { errors: ErrorSummary.from(Cause.squash(cause)) }),
),
Effect.annotateLogs(fields),
),
}
}).pipe(
Effect.onError((cause) =>
Effect.logWarning("mcp oauth authorization setup failed", {
server: input.name,
methodID: input.methodID,
errors: ErrorSummary.from(Cause.squash(cause)),
}),
),
)
})
-5
View File
@@ -344,13 +344,8 @@ function usesAPIKeyAuth(packageName: string | undefined) {
name?.startsWith("@opencode-ai/ai/providers/openai/") === true ||
name === "@opencode-ai/ai/providers/anthropic" ||
name === "@opencode-ai/ai/providers/anthropic-compatible" ||
name === "@opencode-ai/ai/providers/baseten" ||
name === "@opencode-ai/ai/providers/cerebras" ||
name === "@opencode-ai/ai/providers/cloudflare-ai-gateway" ||
name === "@opencode-ai/ai/providers/cloudflare-workers-ai" ||
name === "@opencode-ai/ai/providers/deepinfra" ||
name === "@opencode-ai/ai/providers/deepseek" ||
name === "@opencode-ai/ai/providers/fireworks" ||
name === "@opencode-ai/ai/providers/openai-compatible" ||
name === "@opencode-ai/ai/providers/google" ||
name === "@opencode-ai/ai/providers/groq" ||
+1 -2
View File
@@ -52,7 +52,7 @@ export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fie
}),
)
return yield* Effect.gen(function* () {
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http)).execute(request)
const response = yield* HttpClient.filterStatusOk(http).execute(request)
const body = yield* collectBoundedResponseBody(
response,
MAX_RESPONSE_BYTES,
@@ -60,7 +60,6 @@ export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fie
)
return yield* parseResponse(body.toString("utf8"), schema.output)
}).pipe(
Effect.scoped,
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
+1 -2
View File
@@ -63,11 +63,10 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
max_results: 8,
}),
)
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http))
const response = yield* HttpClient.filterStatusOk(http)
.execute(request)
.pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(SearchResponse)),
Effect.scoped,
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error("Tavily web search request timed out")),
-1
View File
@@ -212,7 +212,6 @@ const layer = Layer.effect(
const row = yield* db
.update(ProjectTable)
.set({
worktree: input.canonical,
name: input.name === undefined ? undefined : input.name || null,
icon_url_override: input.icon?.override === undefined ? undefined : input.icon.override || null,
icon_color: input.icon?.color === undefined ? undefined : input.icon.color || null,
-5
View File
@@ -48,13 +48,8 @@ const builtins = new Map<string, () => Promise<unknown>>([
["@opencode-ai/ai/providers/azure", () => import("@opencode-ai/ai/providers/azure")],
["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")],
["@opencode-ai/ai/providers/azure/responses", () => import("@opencode-ai/ai/providers/azure/responses")],
["@opencode-ai/ai/providers/baseten", () => import("@opencode-ai/ai/providers/baseten")],
["@opencode-ai/ai/providers/cerebras", () => import("@opencode-ai/ai/providers/cerebras")],
["@opencode-ai/ai/providers/cloudflare-ai-gateway", () => import("@opencode-ai/ai/providers/cloudflare-ai-gateway")],
["@opencode-ai/ai/providers/cloudflare-workers-ai", () => import("@opencode-ai/ai/providers/cloudflare-workers-ai")],
["@opencode-ai/ai/providers/deepinfra", () => import("@opencode-ai/ai/providers/deepinfra")],
["@opencode-ai/ai/providers/deepseek", () => import("@opencode-ai/ai/providers/deepseek")],
["@opencode-ai/ai/providers/fireworks", () => import("@opencode-ai/ai/providers/fireworks")],
["@opencode-ai/ai/providers/google", () => import("@opencode-ai/ai/providers/google")],
["@opencode-ai/ai/providers/google-vertex", () => import("@opencode-ai/ai/providers/google-vertex")],
["@opencode-ai/ai/providers/google-vertex/gemini", () => import("@opencode-ai/ai/providers/google-vertex/gemini")],
+7 -14
View File
@@ -28,17 +28,9 @@ export interface Interface {
* Interrupt active work owned by this process. Idle interruption is a no-op. Resolves once
* the interruption is accepted; cleanup settles asynchronously in the execution fiber.
* Returns whether an active execution was interrupted. Compose with `awaitIdle` when
* settlement matters. `awaitSettlement` waits only for the interrupted execution,
* rather than fresh work admitted during its cleanup.
* settlement matters.
*/
readonly interrupt: (
sessionID: SessionSchema.ID,
options?: {
readonly continue?: boolean
readonly reason?: "user" | "inactivity"
readonly awaitSettlement?: boolean
},
) => Effect.Effect<boolean>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
@@ -46,7 +38,7 @@ export interface Interface {
/** Routes execution from a Session ID to its selected instance's runner. */
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionExecution") {}
type InterruptReason = "user" | "shutdown" | "inactivity"
type InterruptReason = "user" | "shutdown"
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: InterruptReason) {
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
@@ -128,8 +120,9 @@ export const layer = Layer.effect(
return
}
if (outcome.type === "interrupted") {
// Deliberate stops release the claim; shutdown keeps it for restart continuity.
if (outcome.reason !== "shutdown") yield* jobs.cancel(sessionID)
// A user cancel releases the claim: the turn must not resurrect at the next
// boot. Shutdown interruption keeps it for restart continuity.
if (outcome.reason === "user") yield* jobs.cancel(sessionID)
yield* bus.publish(
SessionEvent.Execution.Interrupted,
{ sessionID, reason: outcome.reason },
@@ -154,7 +147,7 @@ export const layer = Layer.effect(
isActive: coordinator.isActive,
interrupt: (sessionID, options) =>
Effect.gen(function* () {
const interrupted = yield* coordinator.interrupt(sessionID, options?.reason ?? "user", options)
const interrupted = yield* coordinator.interrupt(sessionID, "user")
if (!options?.continue) return interrupted
// Resume steering input and between-turn control work from the interrupted
// intent. Queued next-turn prompts stay parked: a steer-scoped drain never
+3 -25
View File
@@ -17,14 +17,9 @@ export interface Coordinator<Key, E, Reason = never> {
* Stops the active execution and clears its doorbell. No-op when idle. Resolves once the
* interruption is accepted, not when cleanup settles: the execution fiber finishes its
* finalizers and settled hook on its own time. Returns whether an active execution was
* interrupted. `awaitSettlement` waits for this execution's cleanup and settled hook,
* without following fresh work admitted during cleanup. `awaitIdle` follows successors too.
* interrupted. Compose with `awaitIdle` for settlement.
*/
readonly interrupt: (
key: Key,
reason?: Reason,
options?: { readonly awaitSettlement?: boolean },
) => Effect.Effect<boolean>
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<boolean>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
}
@@ -175,22 +170,5 @@ export const make = <Key, E, Reason = never>(options: {
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(awaitIdle(key)))
})
return {
active: Effect.sync(() => new Set(executions.keys())),
isActive,
run,
wake,
interrupt: (key, reason, options) =>
Effect.suspend(() => {
const execution = executions.get(key)
return interrupt(key, reason).pipe(
Effect.tap(() =>
options?.awaitSettlement && execution
? Deferred.await(execution.done).pipe(Effect.ignoreCause)
: Effect.void,
),
)
}),
awaitIdle,
}
return { active: Effect.sync(() => new Set(executions.keys())), isActive, run, wake, interrupt, awaitIdle }
})
+118 -102
View File
@@ -11,10 +11,6 @@ import { WebSearch } from "../../websearch.js"
export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query."
const providerSelectionLock = Semaphore.makeUnsafe(1)
const httpErrors = new Map([
[429, "Web search rate limited (HTTP 429)"],
[401, "Web search authentication failed (HTTP 401)"],
])
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
@@ -54,100 +50,106 @@ export const Plugin = {
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const search = (providerID?: WebSearch.ID) =>
websearch.query(
{ ...input, providerID },
{
sessionID: context.sessionID,
onProvider: (provider) => context.progress({ provider: provider.id }),
},
)
const result = yield* search().pipe(
Effect.catchTag("WebSearch.ProviderRequired", () => {
return providerSelectionLock
.withPermit(
Effect.gen(function* () {
if (yield* websearch.default()) return
const providers = (yield* ctx.websearch.providers()).data
const defaultProvider = providers[0]
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
description: "Allow OpenCode to search the web for up-to-date information?",
type: "string",
required: true,
custom: false,
options: [
{
value: "allow",
label: `Allow search via ${providers.map((provider) => provider.name).join(", ")}`,
},
{
value: "choose",
label: "Choose another provider",
},
{ value: "disable", label: "Disable web search" },
],
},
],
})
if (response.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* websearch.select(false)
return yield* new WebSearch.DisabledError()
}
const selection =
response.answer.choice === "choose"
? yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
fields: [
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
websearch.default().pipe(
Effect.flatMap((provider) => {
if (!provider) return ctx.websearch.query(input)
return context
.progress({ provider: provider.id })
.pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID: provider.id })))
}),
Effect.catch((error) => {
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
return providerSelectionLock
.withPermit(
Effect.gen(function* () {
if (yield* websearch.default()) return
const providers = (yield* ctx.websearch.providers()).data
const defaultProvider = providers[0]
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
description: "Allow OpenCode to search the web for up-to-date information?",
type: "string",
required: true,
custom: false,
options: [
{
key: "provider",
description: "Choose a provider for web search.",
type: "string",
required: true,
custom: false,
options: providers.map((provider) => ({
value: provider.id,
label: provider.name,
})),
value: "allow",
label: `Allow search via ${providers.map((provider) => provider.name).join(", ")}`,
},
{
value: "choose",
label: "Choose another provider",
},
{ value: "disable", label: "Disable web search" },
],
})
: undefined
if (selection?.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
const providerID = selection?.answer.provider ?? "random"
if (providerID === "random") {
yield* websearch.select("random")
return
}
const provider = providers.find((provider) => provider.id === providerID)
if (!provider) return yield* new WebSearch.ProviderRequiredError()
yield* websearch.select(provider.id)
return provider.id
}),
)
.pipe(
Effect.timeoutOrElse({
duration: "1 minute",
orElse: () => Effect.fail(new Error("Web search cancelled")),
}),
Effect.flatMap(search),
)
}),
)
},
],
})
if (response.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* websearch.select(false)
return yield* new WebSearch.DisabledError()
}
const selection =
response.answer.choice === "choose"
? yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "provider",
description: "Choose a provider for web search.",
type: "string",
required: true,
custom: false,
options: providers.map((provider) => ({
value: provider.id,
label: provider.name,
})),
},
],
})
: undefined
if (selection?.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
const providerID = selection?.answer.provider ?? "random"
if (
typeof providerID !== "string" ||
(providerID !== "random" && !providers.some((provider) => provider.id === providerID))
)
return yield* new WebSearch.ProviderRequiredError()
yield* websearch.select(providerID === "random" ? "random" : WebSearch.ID.make(providerID))
if (providerID !== "random") return WebSearch.ID.make(providerID)
return providers[Math.floor(Math.random() * providers.length)]?.id
}),
)
.pipe(
Effect.timeoutOrElse({
duration: "1 minute",
orElse: () => Effect.fail(new Error("Web search cancelled")),
}),
Effect.flatMap((providerID) => {
if (!providerID) return Effect.suspend(search)
return context
.progress({ provider: providerID })
.pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID })))
}),
)
}),
)
const result = yield* search()
const output = {
provider: result.providerID,
results: result.results,
provider: result.data.providerID,
results: result.data.results,
}
const content = output.results.length
? output.results
@@ -166,14 +168,28 @@ export const Plugin = {
const fallback = `Unable to search the web for ${input.query}`
if (!Schema.is(WebSearch.RequestError)(error)) return new ToolFailure({ message: fallback, error })
const status = HttpClientError.isHttpClientError(error.cause) ? error.cause.response?.status : undefined
return new ToolFailure({
message:
status === undefined
? fallback
: (httpErrors.get(status) ?? `Web search request failed (HTTP ${status})`),
error,
metadata: { provider: error.providerID },
})
switch (status) {
case 429:
return new ToolFailure({
message: "Web search rate limited (HTTP 429)",
error,
metadata: { provider: error.providerID },
})
case 401:
return new ToolFailure({
message: "Web search authentication failed (HTTP 401)",
error,
metadata: { provider: error.providerID },
})
case undefined:
return new ToolFailure({ message: fallback, error, metadata: { provider: error.providerID } })
default:
return new ToolFailure({
message: `Web search request failed (HTTP ${status})`,
error,
metadata: { provider: error.providerID },
})
}
}),
),
}),
-31
View File
@@ -1,31 +0,0 @@
export * as ErrorSummary from "./error-summary.js"
import { Option, Schema } from "effect"
const decode = Schema.decodeUnknownOption(
Schema.Struct({
name: Schema.optional(Schema.String),
_tag: Schema.optional(Schema.String),
code: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
errno: Schema.optional(Schema.Number),
cause: Schema.optional(Schema.Unknown),
}),
)
/** Error messages, stacks and SQL parameters may contain credentials. Retain only diagnostic classifications. */
export function from(error: unknown) {
const errors: { type: string; code?: string | number; errno?: number }[] = []
const seen = new Set<unknown>()
while (error && !seen.has(error) && errors.length < 8) {
seen.add(error)
const result = decode(error)
if (Option.isNone(result)) break
errors.push({
type: result.value._tag ?? (error instanceof Error ? error.name : result.value.name) ?? "unknown",
code: result.value.code,
errno: result.value.errno,
})
error = error instanceof Error ? error.cause : result.value.cause
}
return errors
}

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