mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-18 14:56:27 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1ee2de651 | ||
|
|
80c37bf94a | ||
|
|
941afdb999 | ||
|
|
c2067d59af | ||
|
|
3efbc5e309 | ||
|
|
323472458e | ||
|
|
dc2dedac18 | ||
|
|
59c0e9b369 | ||
|
|
f5d40d6f30 | ||
|
|
c8e654410d | ||
|
|
ed899d5845 | ||
|
|
a57ea33b54 | ||
|
|
8e17cd4284 | ||
|
|
0acae92b7a | ||
|
|
6d465f02e4 | ||
|
|
4b379a2bf1 | ||
|
|
154360948a | ||
|
|
1bf036ab30 | ||
|
|
357b3cb5b3 | ||
|
|
77799084bf | ||
|
|
b1f62b9fda | ||
|
|
6a30ff19a5 | ||
|
|
1c8f2910fd | ||
|
|
d65a5f1372 | ||
|
|
8e72d42142 | ||
|
|
3e6d220a3d | ||
|
|
87f516d219 | ||
|
|
ec7425d6f0 | ||
|
|
461017dcab | ||
|
|
f36788288f |
@@ -8,7 +8,7 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
export type ReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
||||
|
||||
const Options = Schema.Struct({
|
||||
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
|
||||
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
|
||||
enableThinking: Schema.optional(Schema.Boolean),
|
||||
thinkingBudget: Schema.optional(Schema.Int),
|
||||
preserveThinking: Schema.optional(Schema.Boolean),
|
||||
@@ -19,7 +19,7 @@ const Options = Schema.Struct({
|
||||
}),
|
||||
),
|
||||
toolStream: Schema.optional(Schema.Boolean),
|
||||
parallelToolCalls: OpenResponsesOptions.Options.fields.parallelToolCalls,
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
repetitionPenalty: Schema.optional(Schema.Number),
|
||||
responseFormat: Schema.optional(
|
||||
Schema.Struct({
|
||||
|
||||
@@ -6,9 +6,9 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
|
||||
const Options = Schema.Struct({
|
||||
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
|
||||
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
|
||||
enableThinking: Schema.optional(Schema.Boolean),
|
||||
store: OpenResponsesOptions.Options.fields.store,
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
previousResponseId: Schema.optional(Schema.String),
|
||||
conversation: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
@@ -21,11 +21,12 @@ import {
|
||||
type JsonSchema,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ProviderOptions,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolResultPart,
|
||||
} from "../schema/index.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, knownString, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
|
||||
import * as Cache from "./utils/cache.js"
|
||||
@@ -52,57 +53,10 @@ const SSE_EVENTS = new Set([
|
||||
])
|
||||
export const framing = Framing.sseEvents(SSE_EVENTS)
|
||||
|
||||
export type ThinkingBlockBinding = {
|
||||
readonly prefix_mismatch_behavior?: "error" | "drop_block" | (string & {})
|
||||
}
|
||||
|
||||
export type ThinkingInput =
|
||||
| {
|
||||
readonly type: "adaptive"
|
||||
readonly display?: "summarized" | "omitted"
|
||||
readonly block_binding?: ThinkingBlockBinding
|
||||
}
|
||||
| {
|
||||
readonly type: "disabled"
|
||||
}
|
||||
| ({
|
||||
readonly type: "enabled"
|
||||
readonly display?: "summarized" | "omitted"
|
||||
readonly block_binding?: ThinkingBlockBinding
|
||||
} & (
|
||||
| { readonly budgetTokens: number; readonly budget_tokens?: number }
|
||||
| { readonly budgetTokens?: number; readonly budget_tokens: number }
|
||||
))
|
||||
|
||||
export interface OptionsInput {
|
||||
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
|
||||
readonly contextManagement?: ContextManagement
|
||||
readonly [key: string]: unknown
|
||||
readonly thinking?: ThinkingInput
|
||||
readonly effort?: string
|
||||
readonly service_tier?: "auto" | "standard_only"
|
||||
readonly serviceTier?: "auto" | "standard_only"
|
||||
// SDK Metadata:2649 {user_id?: string | null}
|
||||
readonly metadata?: { readonly user_id?: string | null }
|
||||
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
|
||||
readonly container?:
|
||||
| string
|
||||
| { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
|
||||
readonly inference_geo?: string | null
|
||||
readonly inferenceGeo?: string | null
|
||||
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
|
||||
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
|
||||
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
|
||||
readonly output_config?: {
|
||||
readonly effort?: string | null
|
||||
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
|
||||
}
|
||||
readonly outputConfig?: {
|
||||
readonly effort?: string | null
|
||||
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
|
||||
}
|
||||
}
|
||||
|
||||
export type ThinkingBlockBinding = typeof AnthropicThinkingBlockBinding.Type
|
||||
export type ThinkingInput = typeof Thinking.Encoded
|
||||
/** Caller-facing provider options; unknown keys are accepted and ignored. `Options.Type` is the wire-ready form. */
|
||||
export type OptionsInput = ProviderOptions & typeof Options.Encoded
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
|
||||
export const ContextManagement = Schema.Struct({
|
||||
@@ -129,6 +83,7 @@ const AnthropicCacheControl = Schema.Struct({
|
||||
type: Schema.tag("ephemeral"),
|
||||
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
|
||||
})
|
||||
const AnthropicServiceTier = Schema.Literals(["auto", "standard_only"])
|
||||
|
||||
const AnthropicTextBlock = Schema.Struct({
|
||||
type: Schema.tag("text"),
|
||||
@@ -317,25 +272,21 @@ const AnthropicToolChoice = Schema.Union([
|
||||
])
|
||||
|
||||
const AnthropicThinkingBlockBinding = Schema.Struct({
|
||||
prefix_mismatch_behavior: Schema.optional(Schema.String),
|
||||
prefix_mismatch_behavior: Schema.optional(knownString<"error" | "drop_block">()),
|
||||
})
|
||||
|
||||
const AnthropicThinking = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("adaptive"),
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("disabled"),
|
||||
}),
|
||||
])
|
||||
const AnthropicThinkingFields = {
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}
|
||||
const AnthropicThinkingEnabled = Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
...AnthropicThinkingFields,
|
||||
})
|
||||
const AnthropicThinkingAdaptive = Schema.Struct({ type: Schema.tag("adaptive"), ...AnthropicThinkingFields })
|
||||
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.tag("disabled") })
|
||||
const AnthropicThinking = Schema.Union([AnthropicThinkingEnabled, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
|
||||
type AnthropicThinking = typeof AnthropicThinking.Type
|
||||
|
||||
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
|
||||
@@ -360,6 +311,53 @@ const AnthropicContainer = Schema.Union([
|
||||
}),
|
||||
])
|
||||
|
||||
// =============================================================================
|
||||
// Provider Options
|
||||
// =============================================================================
|
||||
// Callers spell the budget as `budgetTokens` or the wire `budget_tokens`; the
|
||||
// keys are disjoint per variant so the input type requires exactly one and the
|
||||
// transform can narrow on it. Decoding straight to the wire block keeps the
|
||||
// alias out of the rest of the file.
|
||||
const ThinkingEnabledInput = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("enabled"), budgetTokens: Schema.Number, ...AnthropicThinkingFields }),
|
||||
Schema.Struct({ type: Schema.tag("enabled"), budget_tokens: Schema.Number, ...AnthropicThinkingFields }),
|
||||
]).pipe(
|
||||
Schema.decodeTo(AnthropicThinkingEnabled, {
|
||||
decode: SchemaGetter.transform((input) => ({
|
||||
type: "enabled" as const,
|
||||
budget_tokens: "budgetTokens" in input ? input.budgetTokens : input.budget_tokens,
|
||||
display: input.display,
|
||||
block_binding: input.block_binding,
|
||||
})),
|
||||
encode: SchemaGetter.passthrough({ strict: false }),
|
||||
}),
|
||||
)
|
||||
const Thinking = Schema.Union([ThinkingEnabledInput, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
|
||||
|
||||
const OutputConfigInput = Schema.Struct({
|
||||
effort: optionalNull(Schema.String),
|
||||
format: optionalNull(AnthropicJsonOutputFormat),
|
||||
})
|
||||
|
||||
// Both key spellings are accepted; `fromRequest` prefers the snake_case one.
|
||||
const Options = Schema.Struct({
|
||||
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
|
||||
contextManagement: Schema.optional(ContextManagement),
|
||||
thinking: Schema.optional(Thinking),
|
||||
effort: Schema.optional(Schema.String),
|
||||
service_tier: Schema.optional(AnthropicServiceTier),
|
||||
serviceTier: Schema.optional(AnthropicServiceTier),
|
||||
metadata: Schema.optional(AnthropicMetadata),
|
||||
container: Schema.optional(AnthropicContainer),
|
||||
inference_geo: optionalNull(Schema.String),
|
||||
inferenceGeo: optionalNull(Schema.String),
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
cacheControl: Schema.optional(AnthropicCacheControl),
|
||||
output_config: Schema.optional(OutputConfigInput),
|
||||
outputConfig: Schema.optional(OutputConfigInput),
|
||||
})
|
||||
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
|
||||
|
||||
const AnthropicBodyFields = {
|
||||
context_management: Schema.optional(
|
||||
Schema.Struct({
|
||||
@@ -391,7 +389,7 @@ const AnthropicBodyFields = {
|
||||
container: Schema.optional(Schema.NullOr(AnthropicContainer)),
|
||||
inference_geo: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
metadata: Schema.optional(AnthropicMetadata),
|
||||
service_tier: Schema.optional(Schema.Literals(["auto", "standard_only"])),
|
||||
service_tier: Schema.optional(AnthropicServiceTier),
|
||||
}
|
||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
@@ -1001,64 +999,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
return messages
|
||||
})
|
||||
|
||||
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
|
||||
const input = request.providerOptions as Record<string, unknown> | undefined
|
||||
const rawServiceTier =
|
||||
(input as Record<string, unknown> | undefined)?.service_tier ??
|
||||
(input as Record<string, unknown> | undefined)?.serviceTier
|
||||
const service_tier =
|
||||
rawServiceTier === "auto" || rawServiceTier === "standard_only"
|
||||
? (rawServiceTier as "auto" | "standard_only")
|
||||
: undefined
|
||||
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
|
||||
const metadata =
|
||||
ProviderShared.isRecord(rawMetadata) && (typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
|
||||
? { user_id: rawMetadata.user_id as string | null }
|
||||
: undefined
|
||||
const container =
|
||||
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
|
||||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
|
||||
? ((input as Record<string, unknown>).container as
|
||||
| string
|
||||
| { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
|
||||
: undefined
|
||||
const rawInferenceGeo =
|
||||
(input as Record<string, unknown> | undefined)?.inference_geo ??
|
||||
(input as Record<string, unknown> | undefined)?.inferenceGeo
|
||||
const inference_geo = typeof rawInferenceGeo === "string" ? rawInferenceGeo : undefined
|
||||
const rawCacheControl =
|
||||
(input as Record<string, unknown> | undefined)?.cache_control ??
|
||||
(input as Record<string, unknown> | undefined)?.cacheControl
|
||||
const cache_control =
|
||||
ProviderShared.isRecord(rawCacheControl) && rawCacheControl.type === "ephemeral"
|
||||
? (rawCacheControl as { type: "ephemeral"; ttl?: "5m" | "1h" })
|
||||
: undefined
|
||||
const rawOutputConfig =
|
||||
(input as Record<string, unknown> | undefined)?.output_config ??
|
||||
(input as Record<string, unknown> | undefined)?.outputConfig
|
||||
const outputConfigEffort =
|
||||
typeof (input as Record<string, unknown> | undefined)?.effort === "string"
|
||||
? ((input as Record<string, unknown>).effort as string)
|
||||
: ProviderShared.isRecord(rawOutputConfig) && typeof rawOutputConfig.effort === "string"
|
||||
? (rawOutputConfig.effort as string)
|
||||
: undefined
|
||||
const outputConfigFormat =
|
||||
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
|
||||
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
|
||||
: undefined
|
||||
const thinking = yield* resolveThinking(input?.thinking)
|
||||
return {
|
||||
thinking: applyThinkingBindingDefault(request.model, thinking),
|
||||
effort: outputConfigEffort,
|
||||
format: outputConfigFormat,
|
||||
service_tier,
|
||||
metadata,
|
||||
container,
|
||||
inference_geo,
|
||||
cache_control,
|
||||
}
|
||||
})
|
||||
|
||||
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
|
||||
const claudeVersion = (id: string) => {
|
||||
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
|
||||
@@ -1097,35 +1037,12 @@ const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: Anthr
|
||||
}
|
||||
}
|
||||
|
||||
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
|
||||
if (!ProviderShared.isRecord(input)) return undefined
|
||||
if (input.type === "disabled") return { type: "disabled" as const }
|
||||
if (input.type !== "adaptive" && input.type !== "enabled") return undefined
|
||||
const block_binding = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(AnthropicThinkingBlockBinding)),
|
||||
)(input.block_binding)
|
||||
const display =
|
||||
input.display === "summarized" || input.display === "omitted"
|
||||
? (input.display as "summarized" | "omitted")
|
||||
: undefined
|
||||
if (input.type === "adaptive") return { type: "adaptive" as const, display, block_binding }
|
||||
const budget =
|
||||
typeof input.budgetTokens === "number"
|
||||
? input.budgetTokens
|
||||
: typeof input.budget_tokens === "number"
|
||||
? input.budget_tokens
|
||||
: undefined
|
||||
if (budget === undefined)
|
||||
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
|
||||
return { type: "enabled" as const, budget_tokens: budget, display, block_binding }
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const options = yield* resolveOptions(request)
|
||||
const updates = resolveEffortUpdates(request, options.effort)
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const management = options.contextManagement
|
||||
const outputConfig = options.output_config ?? options.outputConfig
|
||||
const format = outputConfig?.format ?? undefined
|
||||
const updates = resolveEffortUpdates(request, options.effort ?? outputConfig?.effort ?? undefined)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
@@ -1161,12 +1078,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
)
|
||||
}
|
||||
const output_config =
|
||||
updates.effort === undefined && options.format === undefined
|
||||
? undefined
|
||||
: {
|
||||
...(updates.effort === undefined ? {} : { effort: updates.effort }),
|
||||
...(options.format === undefined ? {} : { format: options.format }),
|
||||
}
|
||||
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
|
||||
const body = {
|
||||
model: request.model.id,
|
||||
system,
|
||||
@@ -1179,14 +1091,14 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
top_p: generation?.topP,
|
||||
top_k: generation?.topK,
|
||||
stop_sequences: generation?.stop,
|
||||
thinking: options.thinking,
|
||||
thinking: applyThinkingBindingDefault(request.model, options.thinking),
|
||||
output_config,
|
||||
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
|
||||
cache_control: options.cache_control,
|
||||
cache_control: options.cache_control ?? options.cacheControl,
|
||||
container: options.container,
|
||||
inference_geo: options.inference_geo,
|
||||
inference_geo: options.inference_geo ?? options.inferenceGeo ?? undefined,
|
||||
metadata: options.metadata,
|
||||
service_tier: options.service_tier,
|
||||
service_tier: options.service_tier ?? options.serviceTier,
|
||||
}
|
||||
if (!management) return body
|
||||
return {
|
||||
|
||||
@@ -14,12 +14,13 @@ import {
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ProviderOptions,
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
} from "../schema/index.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, knownString, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
@@ -50,35 +51,8 @@ const omitsFunctionCallIds = (modelID: string) => {
|
||||
return match !== null && Number(match[1]) < 3
|
||||
}
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly cachedContent?: string
|
||||
readonly safetySettings?: ReadonlyArray<{
|
||||
readonly category:
|
||||
| "HARM_CATEGORY_UNSPECIFIED"
|
||||
| "HARM_CATEGORY_HATE_SPEECH"
|
||||
| "HARM_CATEGORY_DANGEROUS_CONTENT"
|
||||
| "HARM_CATEGORY_HARASSMENT"
|
||||
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
|
||||
| "HARM_CATEGORY_CIVIC_INTEGRITY"
|
||||
| (string & {})
|
||||
readonly threshold:
|
||||
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
|
||||
| "BLOCK_LOW_AND_ABOVE"
|
||||
| "BLOCK_MEDIUM_AND_ABOVE"
|
||||
| "BLOCK_ONLY_HIGH"
|
||||
| "BLOCK_NONE"
|
||||
| "OFF"
|
||||
| (string & {})
|
||||
}>
|
||||
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
|
||||
readonly thinkingConfig?: {
|
||||
readonly thinkingBudget?: number
|
||||
readonly includeThoughts?: boolean
|
||||
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller-facing provider options; unknown keys are accepted and ignored. */
|
||||
export type OptionsInput = ProviderOptions & typeof Options.Encoded
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
|
||||
// =============================================================================
|
||||
@@ -161,17 +135,50 @@ const GeminiToolConfig = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
const GeminiThinkingLevel = knownString<"minimal" | "low" | "medium" | "high">()
|
||||
const GeminiThinkingConfig = Schema.Struct({
|
||||
thinkingBudget: Schema.optional(Schema.Number),
|
||||
includeThoughts: Schema.optional(Schema.Boolean),
|
||||
thinkingLevel: Schema.optional(Schema.String),
|
||||
thinkingLevel: Schema.optional(GeminiThinkingLevel),
|
||||
})
|
||||
|
||||
const GeminiSafetySetting = Schema.Struct({
|
||||
category: Schema.String,
|
||||
threshold: Schema.String,
|
||||
category: knownString<
|
||||
| "HARM_CATEGORY_UNSPECIFIED"
|
||||
| "HARM_CATEGORY_HATE_SPEECH"
|
||||
| "HARM_CATEGORY_DANGEROUS_CONTENT"
|
||||
| "HARM_CATEGORY_HARASSMENT"
|
||||
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
|
||||
| "HARM_CATEGORY_CIVIC_INTEGRITY"
|
||||
>(),
|
||||
threshold: knownString<
|
||||
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
|
||||
| "BLOCK_LOW_AND_ABOVE"
|
||||
| "BLOCK_MEDIUM_AND_ABOVE"
|
||||
| "BLOCK_ONLY_HIGH"
|
||||
| "BLOCK_NONE"
|
||||
| "OFF"
|
||||
>(),
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Provider Options
|
||||
// =============================================================================
|
||||
// Malformed fields are dropped rather than failing the request; a `thinkingConfig`
|
||||
// object that omits `includeThoughts` asks for thoughts.
|
||||
const GeminiThinkingConfigInput = Schema.Struct({
|
||||
thinkingBudget: lenient(Schema.Number),
|
||||
includeThoughts: lenient(Schema.Boolean),
|
||||
thinkingLevel: lenient(GeminiThinkingLevel),
|
||||
})
|
||||
const Options = Schema.Struct({
|
||||
cachedContent: lenient(Schema.String),
|
||||
safetySettings: lenient(Schema.Array(GeminiSafetySetting)),
|
||||
serviceTier: lenient(knownString<"standard" | "flex" | "priority">()),
|
||||
thinkingConfig: lenient(GeminiThinkingConfigInput),
|
||||
})
|
||||
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
|
||||
|
||||
const GeminiGenerationConfig = Schema.Struct({
|
||||
maxOutputTokens: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
@@ -431,44 +438,11 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
return contents
|
||||
})
|
||||
|
||||
const resolveOptions = (request: LLMRequest) => {
|
||||
const input = request.providerOptions
|
||||
const value = input?.thinkingConfig
|
||||
const thinkingConfig = {
|
||||
thinkingBudget:
|
||||
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
|
||||
includeThoughts:
|
||||
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
|
||||
? value.includeThoughts
|
||||
: ProviderShared.isRecord(value)
|
||||
? true
|
||||
: undefined,
|
||||
thinkingLevel:
|
||||
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
|
||||
}
|
||||
return {
|
||||
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
|
||||
safetySettings: mapSafetySettings(input?.safetySettings),
|
||||
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
|
||||
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapSafetySettings(value: unknown) {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
const settings = value.flatMap((item) =>
|
||||
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
|
||||
? [{ category: item.category, threshold: item.threshold }]
|
||||
: [],
|
||||
)
|
||||
return settings
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
const hasTools = flattened.tools.length > 0
|
||||
const generation = request.generation
|
||||
const options = resolveOptions(request)
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const generationConfig = {
|
||||
maxOutputTokens: generation?.maxTokens,
|
||||
@@ -479,7 +453,10 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
presencePenalty: generation?.presencePenalty,
|
||||
seed: generation?.seed,
|
||||
stopSequences: generation?.stop,
|
||||
thinkingConfig: options.thinkingConfig,
|
||||
thinkingConfig:
|
||||
options.thinkingConfig === undefined
|
||||
? undefined
|
||||
: { ...options.thinkingConfig, includeThoughts: options.thinkingConfig.includeThoughts ?? true },
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import {
|
||||
@@ -29,6 +29,16 @@ const isJson = Schema.is(Schema.Json)
|
||||
export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
|
||||
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
|
||||
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
|
||||
/** Optional field whose malformed value decodes to `undefined` instead of failing the enclosing struct. */
|
||||
export const lenient = <const S extends Schema.Top>(schema: S) =>
|
||||
Schema.optionalKey(
|
||||
Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined)))),
|
||||
)
|
||||
/** Provider-defined string enum: known values for autocomplete, any string accepted at runtime. */
|
||||
export const knownString = <Known extends string>() =>
|
||||
Schema.declare<Known | (string & {})>((value): value is Known | (string & {}) => typeof value === "string", {
|
||||
expected: "string",
|
||||
})
|
||||
|
||||
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { Schema } from "effect"
|
||||
import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js"
|
||||
import { lenient } from "../shared.js"
|
||||
|
||||
export { ReasoningEffort, ReasoningEfforts }
|
||||
|
||||
@@ -49,21 +50,22 @@ export const StreamOptions = Schema.Struct({
|
||||
includeObfuscation: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
// Malformed options are dropped one at a time so a bad `topLogprobs` cannot discard `store` or `reasoningEffort`.
|
||||
export const Options = Schema.Struct({
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
safetyIdentifier: Schema.optional(Schema.String),
|
||||
streamOptions: Schema.optional(StreamOptions),
|
||||
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
|
||||
reasoningEffort: Schema.optional(ReasoningEffort),
|
||||
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
|
||||
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
|
||||
textVerbosity: Schema.optional(TextVerbositySchema),
|
||||
serviceTier: Schema.optional(ServiceTierSchema),
|
||||
truncation: Schema.optional(TruncationSchema),
|
||||
allowedTools: Schema.optional(AllowedTools),
|
||||
maxToolCalls: Schema.optional(Schema.Int),
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
store: lenient(Schema.Boolean),
|
||||
metadata: lenient(Schema.Record(Schema.String, Schema.String)),
|
||||
safetyIdentifier: lenient(Schema.String),
|
||||
streamOptions: lenient(StreamOptions),
|
||||
topLogprobs: lenient(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
|
||||
reasoningEffort: lenient(ReasoningEffort),
|
||||
reasoningSummary: lenient(Schema.Literals(["auto", "concise", "detailed"])),
|
||||
include: lenient(Schema.Array(ResponseIncludableSchema)),
|
||||
textVerbosity: lenient(TextVerbositySchema),
|
||||
serviceTier: lenient(ServiceTierSchema),
|
||||
truncation: lenient(TruncationSchema),
|
||||
allowedTools: lenient(AllowedTools),
|
||||
maxToolCalls: lenient(Schema.Int),
|
||||
parallelToolCalls: lenient(Schema.Boolean),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
@@ -71,11 +73,10 @@ export type Resolved = Omit<Options, "allowedTools"> & {
|
||||
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
|
||||
}
|
||||
|
||||
const decodeOptions = Schema.decodeUnknownOption(Options)
|
||||
const decodeOptions = Schema.decodeUnknownSync(Options)
|
||||
|
||||
export const resolve = (request: LLMRequest): Resolved => {
|
||||
const input = Option.getOrUndefined(decodeOptions(request.providerOptions))
|
||||
if (!input) return {}
|
||||
const input = decodeOptions(request.providerOptions ?? {})
|
||||
return {
|
||||
...input,
|
||||
include: input.include?.length ? input.include : undefined,
|
||||
|
||||
@@ -166,7 +166,71 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("Anthropic thinking provider option requires budgetTokens")
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("budgetTokens")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers passthrough provider options and accepts either key spelling", () =>
|
||||
Effect.gen(function* () {
|
||||
const snake = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {
|
||||
service_tier: "auto",
|
||||
metadata: { user_id: "user_1" },
|
||||
container: { id: "container_1" },
|
||||
inference_geo: "us",
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const camel = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {
|
||||
serviceTier: "standard_only",
|
||||
container: "container_2",
|
||||
inferenceGeo: "eu",
|
||||
cacheControl: { type: "ephemeral" },
|
||||
outputConfig: { effort: "low" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(snake.body).toMatchObject({
|
||||
service_tier: "auto",
|
||||
metadata: { user_id: "user_1" },
|
||||
container: { id: "container_1" },
|
||||
inference_geo: "us",
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
|
||||
})
|
||||
expect(camel.body).toMatchObject({
|
||||
service_tier: "standard_only",
|
||||
container: "container_2",
|
||||
inference_geo: "eu",
|
||||
cache_control: { type: "ephemeral" },
|
||||
output_config: { effort: "low" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores unknown provider options and rejects malformed known ones", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { unknownOption: true } }))
|
||||
const malformed = [
|
||||
{ service_tier: "fast" },
|
||||
{ metadata: { user_id: 42 } },
|
||||
{ cache_control: { type: "ephemeral", ttl: "2h" } },
|
||||
{ output_config: { format: { type: "text" } } },
|
||||
{ thinking: { type: "automatic" } },
|
||||
]
|
||||
const errors = yield* Effect.forEach(malformed, (providerOptions) =>
|
||||
compileRequest(LLMRequest.update(request, { providerOptions })).pipe(Effect.flip),
|
||||
)
|
||||
|
||||
expect(prepared.body).not.toHaveProperty("unknownOption")
|
||||
expect(errors.map((error) => error.reason._tag)).toEqual(malformed.map(() => "InvalidRequest"))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -248,6 +248,21 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps valid Chat options when a sibling option is malformed", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
|
||||
prompt: "think",
|
||||
providerOptions: { store: true, reasoningEffort: "max", topLogprobs: 25 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.store).toBe(true)
|
||||
expect(prepared.body.reasoning_effort).toBe("max")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps the request prompt cache key when the compatibility flag is set", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -1945,6 +1945,30 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops a malformed provider option without discarding its siblings", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "hi",
|
||||
providerOptions: {
|
||||
topLogprobs: 25,
|
||||
metadata: { tenant: 7 },
|
||||
reasoningEffort: "high",
|
||||
serviceTier: "priority",
|
||||
maxToolCalls: 4,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.top_logprobs).toBeUndefined()
|
||||
expect(prepared.body.metadata).toBeUndefined()
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "high" })
|
||||
expect(prepared.body.service_tier).toBe("priority")
|
||||
expect(prepared.body.max_tool_calls).toBe(4)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts the full ResponseIncludable union", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -0,0 +1,772 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "/console-auth-project"
|
||||
const location = { directory, project: { id: "proj_console", directory, canonical: directory } }
|
||||
const provider = {
|
||||
id: "opencode",
|
||||
integrationID: "opencode",
|
||||
name: "Anomaly / OpenCode",
|
||||
activation: "enabled",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
}
|
||||
const secondProvider = {
|
||||
...provider,
|
||||
id: "console-google",
|
||||
canonical: "google",
|
||||
name: "Anomaly / Google",
|
||||
package: "@ai-sdk/google",
|
||||
}
|
||||
const directProvider = {
|
||||
...provider,
|
||||
id: "openrouter",
|
||||
integrationID: "openrouter",
|
||||
canonical: "openrouter",
|
||||
name: "OpenRouter",
|
||||
}
|
||||
const model = {
|
||||
id: "sonnet",
|
||||
modelID: "sonnet",
|
||||
providerID: provider.id,
|
||||
name: "Console Sonnet",
|
||||
enabled: true,
|
||||
status: "active",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: [],
|
||||
cost: [],
|
||||
time: { released: 1700000000000 },
|
||||
limit: { context: 200000, output: 32000 },
|
||||
}
|
||||
const models = [
|
||||
model,
|
||||
...Array.from({ length: 18 }, (_, index) => ({
|
||||
...model,
|
||||
id: `model-${index + 2}`,
|
||||
modelID: `model-${index + 2}`,
|
||||
name: `Console Model ${index + 2}`,
|
||||
})),
|
||||
{ ...model, id: "gemini", modelID: "gemini", providerID: secondProvider.id, name: "Console Gemini" },
|
||||
]
|
||||
const directModel = {
|
||||
...model,
|
||||
id: "openrouter-model",
|
||||
modelID: "openrouter-model",
|
||||
providerID: directProvider.id,
|
||||
name: "OpenRouter Model",
|
||||
cost: [{ input: 1, output: 1, cache: { read: 0, write: 0 } }],
|
||||
}
|
||||
const integration = {
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
connections: [],
|
||||
methods: [
|
||||
{
|
||||
id: "device",
|
||||
type: "oauth",
|
||||
label: "OpenCode Console account",
|
||||
form: [
|
||||
{
|
||||
key: "server",
|
||||
type: "string",
|
||||
format: "uri",
|
||||
hidden: true,
|
||||
default: "https://opencode.ai/console",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "key", label: "API key (service account)" },
|
||||
],
|
||||
}
|
||||
|
||||
async function fixture(
|
||||
page: Page,
|
||||
remote = false,
|
||||
options: {
|
||||
draft?: boolean
|
||||
browserFailed?: boolean
|
||||
slowStart?: Promise<void>
|
||||
existingProvider?: boolean
|
||||
singleProvider?: boolean
|
||||
stagedCatalog?: boolean
|
||||
paidModels?: boolean
|
||||
staleIntegration?: boolean
|
||||
directProvider?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const state = {
|
||||
status: "pending",
|
||||
connected: false,
|
||||
starts: 0,
|
||||
cancelled: [] as string[],
|
||||
models: true,
|
||||
modelError: false,
|
||||
statusError: false,
|
||||
startError: false,
|
||||
startGate: options.slowStart,
|
||||
catalogReady: !options.stagedCatalog,
|
||||
}
|
||||
const server = remote ? "http://production.example:4096" : undefined
|
||||
const currentIntegration = () => ({
|
||||
...integration,
|
||||
connections:
|
||||
state.connected && !options.staleIntegration
|
||||
? [{ type: "credential", id: "cred_console", label: "Anomaly" }]
|
||||
: [],
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
server,
|
||||
directory,
|
||||
provider: [],
|
||||
sessions: [],
|
||||
project: {
|
||||
id: "proj_console",
|
||||
canonical: directory,
|
||||
name: "Console test",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page
|
||||
.context()
|
||||
.route("https://console.example/**", (route) =>
|
||||
route.fulfill({ contentType: "text/html", body: "<title>Console fixture</title><p>Authorize access</p>" }),
|
||||
)
|
||||
await page.route("**/api/integration**", async (route) => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname
|
||||
if (request.method() === "OPTIONS") return route.fallback()
|
||||
const headers = { "access-control-allow-origin": "*" }
|
||||
const json = (data: unknown) => route.fulfill({ headers, json: { location, data } })
|
||||
if (path === "/api/integration") return json([currentIntegration()])
|
||||
if (path === "/api/integration/opencode") return json(currentIntegration())
|
||||
if (path === "/api/integration/opencode/connect/oauth") {
|
||||
expect(request.postDataJSON()).toEqual({
|
||||
methodID: "device",
|
||||
answer: { server: "https://opencode.ai/console" },
|
||||
})
|
||||
state.starts++
|
||||
if (state.startGate) await state.startGate
|
||||
if (state.startError) return route.fulfill({ status: 503, headers })
|
||||
return json({
|
||||
attemptID: `con_${state.starts}`,
|
||||
mode: "auto",
|
||||
instructions: "Confirmation code: TFXS-STXG",
|
||||
url: "https://console.example/device?user_code=TFXS-STXG&client_id=opencode-cli",
|
||||
time: { created: Date.now(), expires: Date.now() + 60000 },
|
||||
})
|
||||
}
|
||||
if (path.includes("/connect/oauth/con_")) {
|
||||
if (request.method() === "DELETE") {
|
||||
state.cancelled.push(path.split("/").pop()!)
|
||||
return route.fulfill({ status: 204, headers })
|
||||
}
|
||||
if (state.statusError) return route.fulfill({ status: 503, headers })
|
||||
if (state.status === "complete") state.connected = true
|
||||
return json({
|
||||
status: state.status,
|
||||
...(state.status === "failed" ? { message: "Device authorization failed: access_denied" } : {}),
|
||||
time: { created: 0, expires: Date.now() + 60000 },
|
||||
})
|
||||
}
|
||||
return route.fallback()
|
||||
})
|
||||
await page.route("**/api/provider**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
return route.fulfill({
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
json: {
|
||||
location,
|
||||
data: !state.connected
|
||||
? options.existingProvider
|
||||
? [directProvider]
|
||||
: []
|
||||
: state.catalogReady
|
||||
? [provider, ...(options.singleProvider ? [] : [secondProvider])].concat(
|
||||
options.directProvider || options.existingProvider ? [directProvider] : [],
|
||||
)
|
||||
: [{ ...provider, name: "OpenCode Zen" }],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route("**/api/model**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
if (state.modelError) return route.fulfill({ status: 503, headers: { "access-control-allow-origin": "*" } })
|
||||
const available = state.connected && state.models
|
||||
const source = options.directProvider || options.existingProvider ? [...models, directModel] : models
|
||||
const catalog = options.paidModels
|
||||
? source.map((model) => ({ ...model, cost: [{ input: 1, output: 1, cache: { read: 0, write: 0 } }] }))
|
||||
: source
|
||||
return route.fulfill({
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
json: {
|
||||
location,
|
||||
data: new URL(route.request().url()).pathname.endsWith("/default")
|
||||
? available
|
||||
? catalog[0]
|
||||
: null
|
||||
: available
|
||||
? !state.catalogReady
|
||||
? catalog.filter((model) => model.providerID === provider.id).slice(0, 6)
|
||||
: options.singleProvider
|
||||
? catalog.filter((model) => model.providerID === provider.id)
|
||||
: catalog
|
||||
: options.existingProvider
|
||||
? [directModel]
|
||||
: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route("**/api/credential/**", async (route) => {
|
||||
if (route.request().method() !== "DELETE") return route.fallback()
|
||||
state.connected = false
|
||||
state.status = "pending"
|
||||
await route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
await page.evaluate(() => {
|
||||
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
|
||||
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
|
||||
host.__mockServerStream.push([
|
||||
{ id: "evt_credential_removed", type: "credential.updated", data: {} },
|
||||
{
|
||||
id: "evt_credential_switched",
|
||||
type: "credential.switched",
|
||||
data: { integrationID: "opencode", credentialID: null },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server }) => {
|
||||
if (server) localStorage.setItem("opencode.settings.dat:defaultServerUrl", server)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
list: server ? [{ type: "http", displayName: "Production server", http: { url: server } }] : [],
|
||||
projects: { [server ?? "local"]: [{ worktree: directory, expanded: true }] },
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ directory, server },
|
||||
)
|
||||
const params = new URLSearchParams()
|
||||
if (server) params.set("server", server)
|
||||
if (options.browserFailed) params.set("browserFailed", "1")
|
||||
await page.goto(`/e2e/desktop/index.html?${params}`)
|
||||
const dialog = page.locator('[data-component="dialog-v2"]').getByRole("dialog")
|
||||
if (options.draft) {
|
||||
await page.keyboard.press("Control+t")
|
||||
const composer = page.locator('[data-component="composer-editor"]')
|
||||
await expect(composer).toBeEditable()
|
||||
await composer.fill("Keep this draft throughout sign-in")
|
||||
const tip = page.locator('[data-component="new-session-tip"]')
|
||||
await expect(tip).toContainText("Connect to 75+ providers")
|
||||
await tip.getByRole("button", { name: /Connect to 75\+ providers/ }).click()
|
||||
await dialog.getByRole("button", { name: /^OpenCode / }).click()
|
||||
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
|
||||
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
|
||||
return { state, dialog }
|
||||
}
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
await page.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
// Use the picker so this also exercises the existing Settings entry point.
|
||||
await page.getByRole("button", { name: "Show more providers", exact: true }).click()
|
||||
await page
|
||||
.getByRole("dialog")
|
||||
.getByRole("button", { name: /^OpenCode / })
|
||||
.click()
|
||||
await expect(dialog.getByRole("button", { name: "Continue to OpenCode Console" })).toBeEnabled()
|
||||
return { state, dialog }
|
||||
}
|
||||
|
||||
test("Console account is primary and the code is displayed without a copy-code step", async ({ page }) => {
|
||||
const { state, dialog } = await fixture(page)
|
||||
await expect(dialog.getByRole("heading", { name: "Connect OpenCode", exact: true })).toBeVisible()
|
||||
await expect(dialog.getByText("Service account?", { exact: true })).toBeVisible()
|
||||
await expect(dialog.getByRole("button", { name: "Use API key", exact: true })).toBeVisible()
|
||||
const shell = await dialog.boundingBox()
|
||||
const back = await dialog.getByRole("button", { name: "Navigate back" }).boundingBox()
|
||||
const heading = await dialog.getByRole("heading", { name: "Connect OpenCode", exact: true }).boundingBox()
|
||||
const logo = await dialog.locator('[data-component="opencode-logo"]').boundingBox()
|
||||
const description = await dialog
|
||||
.getByText("Sign in with your OpenCode Console account to use the available models.")
|
||||
.boundingBox()
|
||||
const primary = await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).boundingBox()
|
||||
const service = await dialog.locator('[data-component="console-service-account"]').boundingBox()
|
||||
if (!shell || !back || !heading || !logo || !description || !primary || !service)
|
||||
throw new Error("Missing dialog layout")
|
||||
expect(shell.height).toBe(512)
|
||||
expect(back.x - shell.x).toBe(20)
|
||||
expect(back.y - shell.y).toBe(16)
|
||||
expect(heading.y - (back.y + back.height)).toBe(12)
|
||||
expect(logo.y + logo.height / 2).toBe(heading.y + heading.height / 2)
|
||||
expect(description.y - (heading.y + heading.height)).toBe(24)
|
||||
expect(primary.y - (description.y + description.height)).toBe(20)
|
||||
expect(service.y - (primary.y + primary.height)).toBe(20)
|
||||
await page.screenshot({ path: test.info().outputPath("connect-console-light.png") })
|
||||
const popup = page.waitForEvent("popup")
|
||||
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
|
||||
const consolePage = await popup
|
||||
await expect(consolePage).toHaveURL(/user_code=TFXS-STXG/)
|
||||
await expect(consolePage).toHaveURL(/client_id=opencode-desktop/)
|
||||
await expect(consolePage).toHaveURL(/return_window=console-auth-fixture/)
|
||||
await expect(
|
||||
dialog.getByText("Continue in your browser. Confirm the code shown there matches the one below."),
|
||||
).toBeVisible()
|
||||
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("button", { name: "Copy sign-in link" })).toBeVisible()
|
||||
const authHeading = await dialog.getByRole("heading", { name: "Connect OpenCode Console account" }).boundingBox()
|
||||
const authDescription = await dialog
|
||||
.getByText("Continue in your browser. Confirm the code shown there matches the one below.")
|
||||
.boundingBox()
|
||||
const label = await dialog.getByText("Device code", { exact: true }).boundingBox()
|
||||
const code = await dialog.getByRole("group", { name: "Device code: TFXS-STXG" }).boundingBox()
|
||||
const waiting = await dialog.getByRole("status").boundingBox()
|
||||
const fallback = await dialog.locator('[data-component="console-browser-fallback"]').boundingBox()
|
||||
const authShell = await dialog.boundingBox()
|
||||
if (!authHeading || !authDescription || !label || !code || !waiting || !fallback || !authShell)
|
||||
throw new Error("Missing authorization layout")
|
||||
expect(authDescription.y - (authHeading.y + authHeading.height)).toBe(24)
|
||||
expect(label.y - (authDescription.y + authDescription.height)).toBe(20)
|
||||
expect(code.y - (label.y + label.height)).toBe(8)
|
||||
expect(code.height).toBe(48)
|
||||
expect(waiting.y - (code.y + code.height)).toBe(8)
|
||||
expect(fallback.y - (waiting.y + waiting.height)).toBe(20)
|
||||
expect(authShell.height).toBeLessThan(512)
|
||||
expect(authShell.y + authShell.height - (fallback.y + fallback.height)).toBe(16)
|
||||
await page.screenshot({ path: test.info().outputPath("console-auth-light.png") })
|
||||
await page.emulateMedia({ colorScheme: "dark" })
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||
await page.screenshot({ path: test.info().outputPath("console-auth-dark.png") })
|
||||
state.status = "complete"
|
||||
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode Console" })).toBeVisible()
|
||||
const list = dialog.getByRole("radiogroup", { name: "Models available from OpenCode Console" })
|
||||
const available = dialog.locator('[data-component="available-models-heading"]')
|
||||
await expect(available).toContainText("Available models")
|
||||
await expect(available).toContainText("Anomaly")
|
||||
await expect(dialog.getByRole("button", { name: "OpenCode", exact: true })).toBeVisible()
|
||||
await expect(dialog.getByRole("button", { name: "Google", exact: true })).toBeVisible()
|
||||
await expect(list.getByRole("radio")).toHaveCount(models.length)
|
||||
await page.mouse.move(0, 0)
|
||||
const first = list.getByRole("radio", { name: "Console Sonnet" })
|
||||
await expect(first).toBeChecked()
|
||||
await expect(first).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
|
||||
await expect(dialog.locator('[data-component="settings-list"]')).toHaveCount(2)
|
||||
await expect(first.locator('[data-slot="settings-row-title"]')).toHaveCSS("font-weight", "440")
|
||||
await expect(first).toHaveCSS("border-radius", "4px")
|
||||
const providerGroups = dialog.locator('[data-component="provider-model-group"]')
|
||||
await expect(providerGroups).toHaveCount(2)
|
||||
const openCodeGroup = dialog.locator('[data-component="provider-model-group"][data-provider="opencode"]')
|
||||
await expect(openCodeGroup).toHaveCSS("border-radius", "8px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
openCodeGroup.evaluate((element) => {
|
||||
const list = element.querySelector<HTMLElement>('[data-component="settings-list"]')
|
||||
if (!list) return false
|
||||
const background = getComputedStyle(element).backgroundColor
|
||||
return background !== "rgba(0, 0, 0, 0)" && getComputedStyle(list).backgroundColor === "rgba(0, 0, 0, 0)"
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(providerGroups.getByText(/models? enabled$/)).toHaveCount(0)
|
||||
const google = dialog.getByRole("button", { name: "Google", exact: true })
|
||||
await expect(
|
||||
dialog.locator(
|
||||
'[data-component="provider-model-group"][data-provider="console-google"] [data-component="provider-icon"] use',
|
||||
),
|
||||
).toHaveAttribute("href", /#google$/)
|
||||
const gemini = list.getByRole("radio", { name: "Console Gemini" })
|
||||
const geminiShell = list
|
||||
.locator('[data-component="connected-model-row-shell"]')
|
||||
.filter({ hasText: /^Console Gemini$/ })
|
||||
await expect(gemini).toHaveCSS("height", "40px")
|
||||
await expect(geminiShell).toHaveCSS("margin-left", "4px")
|
||||
await google.click()
|
||||
await expect(google).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(gemini).toBeHidden()
|
||||
await google.click()
|
||||
await expect(google).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(dialog.locator('[data-slot="dialog-header"]')).toHaveCSS("padding-top", "20px")
|
||||
const hovered = list.getByRole("radio", { name: "Console Model 3" })
|
||||
const hoveredShell = list
|
||||
.locator('[data-component="connected-model-row-shell"]')
|
||||
.filter({ hasText: /^Console Model 3$/ })
|
||||
await expect(hoveredShell).toHaveCSS("margin-left", "4px")
|
||||
await expect(hoveredShell).toHaveCSS("padding-top", "4px")
|
||||
await expect(hovered).toHaveCSS("height", "40px")
|
||||
await hovered.hover()
|
||||
await expect
|
||||
.poll(() => hovered.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.not.toBe("rgba(0, 0, 0, 0)")
|
||||
await expect(hovered).toHaveCSS("border-bottom-width", "0px")
|
||||
await expect(list.getByRole("radio", { name: "Console Model 2" })).toHaveCSS("border-bottom-width", "0px")
|
||||
await page.screenshot({ path: test.info().outputPath("first-provider-models-dark.png") })
|
||||
await list.getByRole("radio", { name: "Console Model 2" }).click()
|
||||
await expect(list.getByRole("radio", { name: "Console Model 2" })).toBeChecked()
|
||||
const scroll = dialog.locator('[data-component="first-provider-model-scroll"]')
|
||||
const footer = dialog.locator('[data-component="first-provider-model-footer"]')
|
||||
const footerBefore = await footer.boundingBox()
|
||||
expect(await scroll.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
|
||||
await scroll.evaluate((element) => element.scrollTo({ top: element.scrollHeight }))
|
||||
await expect(list.getByRole("radio", { name: models.at(-1)!.name })).toBeInViewport()
|
||||
expect(await footer.boundingBox()).toEqual(footerBefore)
|
||||
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText("Console Model 2")
|
||||
expect(state.starts).toBe(1)
|
||||
expect(state.cancelled).toEqual([])
|
||||
})
|
||||
|
||||
test("Manage models groups Console providers like Settings", async ({ page }) => {
|
||||
const { state, dialog } = await fixture(page, false, { draft: true, paidModels: true, directProvider: true })
|
||||
state.status = "complete"
|
||||
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode Console" })).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
|
||||
await page.locator('[data-action="composer-model"]').click()
|
||||
const search = page.getByPlaceholder("Search models", { exact: true })
|
||||
await expect(search).toBeFocused()
|
||||
await search.press("ArrowUp")
|
||||
await search.press("Enter")
|
||||
|
||||
await expect(dialog.getByRole("heading", { name: "Manage models", exact: true })).toBeVisible()
|
||||
const managed = dialog.locator('[data-component="manage-models-console"]')
|
||||
await expect(managed.getByRole("button", { name: "OpenCode Anomaly", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(managed.locator('[data-component="provider-model-group"]')).toHaveCount(2)
|
||||
await expect(managed.getByRole("button", { name: /^Google \d+ models? enabled$/ })).toBeVisible()
|
||||
await expect(managed.getByText("Anomaly / Google", { exact: true })).toHaveCount(0)
|
||||
await expect(dialog.getByRole("button", { name: "OpenRouter", exact: true })).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Connect provider", exact: true }).click()
|
||||
await expect(dialog.getByRole("heading", { name: "Connect provider", exact: true })).toBeVisible()
|
||||
await expect(dialog.getByRole("button", { name: /^OpenCode Reliable optimized models/ })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("Manage models opens the Models settings page", async ({ page }) => {
|
||||
const { state, dialog } = await fixture(page)
|
||||
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
|
||||
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
|
||||
state.status = "complete"
|
||||
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode Console" })).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Manage models", exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(page.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
const console = page.locator('[data-component="settings-models-console"]')
|
||||
const consoleToggle = console.getByRole("button", { name: "OpenCode Anomaly", exact: true })
|
||||
await expect(consoleToggle).toHaveAttribute("aria-expanded", "true")
|
||||
const groups = console.locator('[data-component="provider-model-group"]')
|
||||
await expect(groups).toHaveCount(2)
|
||||
await expect(console.locator(".settings-models-console-groups")).toHaveCSS("border-inline-start-style", "solid")
|
||||
const openCode = console.locator('[data-component="provider-model-group"][data-provider="opencode"]')
|
||||
await expect(openCode).toBeVisible()
|
||||
const openCodeToggle = openCode.getByRole("button", { name: "OpenCode 0 models enabled", exact: true })
|
||||
await expect(openCodeToggle).toBeVisible()
|
||||
const divider = openCode.locator('[data-component="settings-list"]')
|
||||
await openCodeToggle.hover()
|
||||
await expect(openCode).toHaveCSS("outline-style", "solid")
|
||||
await expect(divider).toHaveCSS("border-top-color", "rgba(0, 0, 0, 0)")
|
||||
await expect(openCodeToggle.locator(".provider-model-group-chevron")).toHaveCSS("margin-left", "-2px")
|
||||
await openCode.getByRole("switch", { name: "Console Model 2" }).press("Space")
|
||||
await expect(openCode.getByRole("button", { name: "OpenCode 1 model enabled", exact: true })).toBeVisible()
|
||||
await consoleToggle.click()
|
||||
await expect(groups).toHaveCount(0)
|
||||
await consoleToggle.click()
|
||||
await expect(groups).toHaveCount(2)
|
||||
await page.screenshot({ path: test.info().outputPath("settings-console-models.png") })
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.dir = "rtl"
|
||||
})
|
||||
await expect(console.locator(".settings-models-console-groups")).toHaveCSS("border-right-style", "solid")
|
||||
await page.screenshot({ path: test.info().outputPath("settings-console-models-rtl.png") })
|
||||
})
|
||||
|
||||
test("a single managed provider uses a collapsible container", async ({ page }) => {
|
||||
const { state, dialog } = await fixture(page, false, { singleProvider: true })
|
||||
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
|
||||
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
|
||||
state.status = "complete"
|
||||
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode Console" })).toBeVisible()
|
||||
await expect(dialog.getByRole("button", { name: "Anomaly / OpenCode", exact: true })).toHaveCount(0)
|
||||
const provider = dialog.getByRole("button", { name: "OpenCode", exact: true })
|
||||
await expect(provider).toHaveAttribute("aria-expanded", "true")
|
||||
await provider.click()
|
||||
await expect(dialog.getByRole("radio", { name: "Console Sonnet" })).toBeHidden()
|
||||
await provider.click()
|
||||
await expect(dialog.getByRole("radio", { name: "Console Sonnet" })).toBeVisible()
|
||||
await expect(dialog.locator('[data-component="available-models-heading"]')).toContainText("Available models")
|
||||
})
|
||||
|
||||
test("model choice is skipped after a provider has already been connected", async ({ page }) => {
|
||||
const { state, dialog } = await fixture(page, false, { existingProvider: true })
|
||||
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
|
||||
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
|
||||
state.status = "complete"
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(page.getByRole("tab", { name: "Providers", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(page.getByText("OpenCode Console connected", { exact: true })).toBeVisible()
|
||||
const connected = page.locator('[data-component="connected-providers-section"]')
|
||||
await connected.getByRole("button", { name: "2 providers available", exact: true }).click()
|
||||
await connected.getByRole("button", { name: "Google", exact: true }).click()
|
||||
await expect(page.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
const card = page.locator('[data-component="provider-model-group"][data-provider="console-google"]')
|
||||
const search = page.getByRole("searchbox", { name: "Search models", exact: true })
|
||||
await expect(card).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const cardBox = await card.boundingBox()
|
||||
const searchBox = await search.boundingBox()
|
||||
if (!cardBox || !searchBox) return false
|
||||
return cardBox.y >= searchBox.y + searchBox.height + 20
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test("Console reconnect clears disconnected provider suppression", async ({ page }) => {
|
||||
const { state, dialog } = await fixture(page)
|
||||
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
|
||||
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
|
||||
state.status = "complete"
|
||||
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode Console" })).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).click()
|
||||
|
||||
const connected = page.locator('[data-component="connected-providers-section"]')
|
||||
await expect(connected.getByText("OpenCode", { exact: true })).toBeVisible()
|
||||
await connected.getByRole("button", { name: "Disconnect", exact: true }).click()
|
||||
await expect(connected).toContainText("No connected providers")
|
||||
const popular = page.getByRole("heading", { name: "Popular providers", exact: true }).locator("..")
|
||||
await expect(popular.getByRole("button", { name: "Connect", exact: true })).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "Show more providers", exact: true }).click()
|
||||
await dialog.getByRole("button", { name: /^OpenCode / }).click()
|
||||
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
|
||||
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
|
||||
state.status = "complete"
|
||||
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode Console" })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(connected.getByText("OpenCode", { exact: true })).toBeVisible()
|
||||
await expect(connected.getByText("Anomaly", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("service-account API key form matches the Console dialog layout", async ({ page }) => {
|
||||
const { dialog } = await fixture(page)
|
||||
const initialShell = await dialog.boundingBox()
|
||||
await dialog.getByRole("button", { name: "Use API key", exact: true }).click()
|
||||
await expect(dialog.getByRole("heading", { name: "Connect OpenCode", exact: true })).toBeVisible()
|
||||
const description = dialog.getByText("Connect using a service-account API key from OpenCode Console.")
|
||||
const label = dialog.locator('[data-component="provider-api-key-label"]')
|
||||
const input = dialog.getByLabel("OpenCode Console API key", { exact: true })
|
||||
const button = dialog.getByRole("button", { name: "Continue", exact: true })
|
||||
await expect(input).toBeFocused()
|
||||
const shell = await dialog.boundingBox()
|
||||
const heading = await dialog.getByRole("heading", { name: "Connect OpenCode", exact: true }).boundingBox()
|
||||
const descriptionBox = await description.boundingBox()
|
||||
const labelBox = await label.boundingBox()
|
||||
const fieldBox = await input.locator("..").locator("..").boundingBox()
|
||||
const buttonBox = await button.boundingBox()
|
||||
if (!shell || !heading || !descriptionBox || !labelBox || !fieldBox || !buttonBox)
|
||||
throw new Error("Missing API key dialog layout")
|
||||
if (!initialShell) throw new Error("Missing initial Console dialog layout")
|
||||
expect(shell.height).toBe(512)
|
||||
expect(shell.height).toBe(initialShell.height)
|
||||
expect(descriptionBox.y - (heading.y + heading.height)).toBe(24)
|
||||
expect(labelBox.y - (descriptionBox.y + descriptionBox.height)).toBe(20)
|
||||
expect(fieldBox.y - (labelBox.y + labelBox.height)).toBe(8)
|
||||
expect(buttonBox.y - (fieldBox.y + fieldBox.height)).toBe(20)
|
||||
await page.screenshot({ path: test.info().outputPath("console-api-key-light.png") })
|
||||
})
|
||||
|
||||
test("setup preserves the draft and Continue restores composer focus", async ({ page }) => {
|
||||
const { state, dialog } = await fixture(page, false, { draft: true })
|
||||
state.status = "complete"
|
||||
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode Console" })).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
|
||||
const composer = page.locator('[data-component="composer-editor"]')
|
||||
await expect(composer).toHaveText("Keep this draft throughout sign-in")
|
||||
await expect(composer).toBeFocused()
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText("Console Sonnet")
|
||||
})
|
||||
|
||||
test("catalog refresh failure retries without asking for authorization again", async ({ page }) => {
|
||||
const { state, dialog } = await fixture(page)
|
||||
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
|
||||
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
|
||||
state.modelError = true
|
||||
state.status = "complete"
|
||||
await expect(dialog.getByRole("alert")).toContainText("Your account is connected, but we couldn't load your models")
|
||||
state.modelError = false
|
||||
await dialog.getByRole("button", { name: "Try again", exact: true }).click()
|
||||
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode Console" })).toBeVisible()
|
||||
expect(state.starts).toBe(1)
|
||||
})
|
||||
|
||||
test("status request failure resumes the existing attempt", async ({ page }) => {
|
||||
const { state, dialog } = await fixture(page)
|
||||
state.statusError = true
|
||||
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
|
||||
const alert = dialog.getByRole("alert")
|
||||
await expect(alert).toBeVisible()
|
||||
await expect(alert).toHaveClass(/text-v2-text-text-base/)
|
||||
await expect(alert.locator("svg")).toHaveClass(/text-v2-state-fg-danger/)
|
||||
state.statusError = false
|
||||
state.status = "complete"
|
||||
await dialog.getByRole("button", { name: "Try again", exact: true }).click()
|
||||
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode Console" })).toBeVisible()
|
||||
expect(state.starts).toBe(1)
|
||||
expect(state.cancelled).toEqual([])
|
||||
})
|
||||
|
||||
test("retrying authorization startup keeps the error view busy", async ({ page }) => {
|
||||
const { state, dialog } = await fixture(page)
|
||||
state.startError = true
|
||||
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
|
||||
const alert = dialog.getByRole("alert")
|
||||
await expect(alert).toContainText("Couldn't start sign-in")
|
||||
await expect(dialog.getByRole("heading", { name: "Connect to OpenCode", exact: true })).toBeVisible()
|
||||
await expect(dialog.locator('[data-component="provider-connect-content"]')).toHaveCSS("padding-left", "12px")
|
||||
|
||||
const retry = Promise.withResolvers<void>()
|
||||
state.startError = false
|
||||
state.startGate = retry.promise
|
||||
await dialog.getByRole("button", { name: "Try again", exact: true }).click()
|
||||
const opening = dialog.getByRole("button", { name: "Opening browser…", exact: true })
|
||||
await expect(opening).toBeDisabled()
|
||||
await expect(alert).toContainText("Couldn't start sign-in")
|
||||
|
||||
const popup = page.waitForEvent("popup")
|
||||
retry.resolve()
|
||||
await popup
|
||||
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
|
||||
})
|
||||
|
||||
test("backdrop clicks do not cancel Console authorization", async ({ page }) => {
|
||||
const { state, dialog } = await fixture(page)
|
||||
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
|
||||
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
|
||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 8, y: 8 } })
|
||||
await expect(dialog).toBeVisible()
|
||||
expect(state.cancelled).toEqual([])
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect.poll(() => state.cancelled).toEqual(["con_1"])
|
||||
})
|
||||
|
||||
test("first connection waits for the managed Console catalog", async ({ page }) => {
|
||||
const { state, dialog } = await fixture(page, false, { stagedCatalog: true, directProvider: true })
|
||||
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
|
||||
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
|
||||
state.status = "complete"
|
||||
await expect(dialog.getByRole("status")).toContainText("Waiting for confirmation")
|
||||
await expect(dialog.getByText("OpenCode connected. Loading your models", { exact: true })).toHaveCount(0)
|
||||
await expect(dialog.locator('[data-component="first-provider-models"]')).toHaveCount(0)
|
||||
const connected = page.locator('[data-component="connected-providers-section"]')
|
||||
await expect(connected.getByText("OpenCode Zen", { exact: true })).toHaveCount(0)
|
||||
|
||||
state.catalogReady = true
|
||||
await page.evaluate((directory) => {
|
||||
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
|
||||
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
|
||||
host.__mockServerStream.push([
|
||||
{ id: "evt_console_provider", type: "provider.updated", location: { directory }, data: {} },
|
||||
])
|
||||
}, directory)
|
||||
|
||||
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode Console" })).toBeVisible()
|
||||
await expect(dialog.locator('[data-component="provider-model-group"]')).toHaveCount(2)
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(connected.getByText("OpenCode", { exact: true })).toBeVisible()
|
||||
await expect(connected.getByText("Anomaly", { exact: true })).toBeVisible()
|
||||
const consoleRow = connected.locator(".settings-provider-console-header")
|
||||
const directRow = connected.locator(".settings-provider-row").filter({ hasText: "OpenRouter" })
|
||||
await expect(directRow).toBeVisible()
|
||||
await expect.poll(async () => (await directRow.boundingBox())?.height).toBe((await consoleRow.boundingBox())?.height)
|
||||
})
|
||||
|
||||
test("closing during authorization startup cancels the late server attempt", async ({ page }) => {
|
||||
const start = Promise.withResolvers<void>()
|
||||
const { state, dialog } = await fixture(page, false, { slowStart: start.promise })
|
||||
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
|
||||
await expect.poll(() => state.starts).toBe(1)
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
start.resolve()
|
||||
await expect.poll(() => state.cancelled).toEqual(["con_1"])
|
||||
})
|
||||
|
||||
test("authorization startup stays on the Continue button until the device code is ready", async ({ page }) => {
|
||||
const start = Promise.withResolvers<void>()
|
||||
const { dialog } = await fixture(page, false, { slowStart: start.promise })
|
||||
const button = dialog.getByRole("button", { name: "Continue to OpenCode Console" })
|
||||
await button.click()
|
||||
await expect(dialog.getByRole("button", { name: "Opening browser…" })).toHaveAttribute("aria-busy", "true")
|
||||
await expect(dialog.getByRole("heading", { name: "Connect OpenCode", exact: true })).toBeVisible()
|
||||
await expect(dialog.getByRole("group", { name: /Device code/ })).toHaveCount(0)
|
||||
start.resolve()
|
||||
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
|
||||
})
|
||||
|
||||
test("browser failure offers a copyable sign-in link in a narrow RTL window", async ({ page, context }) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"])
|
||||
const { dialog } = await fixture(page, false, { browserFailed: true })
|
||||
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
|
||||
await expect(dialog.getByText(/We couldn't open your browser/)).toBeVisible()
|
||||
await page.setViewportSize({ width: 380, height: 650 })
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.dir = "rtl"
|
||||
})
|
||||
const code = dialog.getByRole("group", { name: "Device code: TFXS-STXG" })
|
||||
await expect(code).toHaveCSS("direction", "ltr")
|
||||
await expect(code).toBeInViewport()
|
||||
await dialog.getByRole("button", { name: "Copy sign-in link" }).click()
|
||||
await expect(dialog.getByRole("button", { name: "Sign-in link copied" })).toBeVisible()
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(
|
||||
"https://console.example/device?user_code=TFXS-STXG&client_id=opencode-desktop&return_window=console-auth-fixture",
|
||||
)
|
||||
await expect(dialog.getByRole("button", { name: "Open Console again" })).toBeInViewport()
|
||||
await page.screenshot({ path: test.info().outputPath("console-auth-narrow-rtl.png") })
|
||||
})
|
||||
|
||||
test("cancel releases the server attempt and retrying expiration creates a new attempt", async ({ page }) => {
|
||||
const { state, dialog } = await fixture(page)
|
||||
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
|
||||
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
|
||||
state.status = "expired"
|
||||
await expect(dialog.getByRole("alert")).toContainText("has expired")
|
||||
state.status = "pending"
|
||||
await dialog.getByRole("button", { name: "Try again", exact: true }).click()
|
||||
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
|
||||
await expect.poll(() => state.starts).toBe(2)
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect.poll(() => state.cancelled).toEqual(["con_1", "con_2"])
|
||||
})
|
||||
|
||||
test("an authorized workspace without models stays connected and can refresh", async ({ page }) => {
|
||||
const { state, dialog } = await fixture(page)
|
||||
state.models = false
|
||||
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
|
||||
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
|
||||
state.status = "complete"
|
||||
await expect(dialog.getByText(/this Console workspace has no available models/)).toBeVisible()
|
||||
state.models = true
|
||||
await dialog.getByRole("button", { name: "Refresh models" }).click()
|
||||
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode Console" })).toBeVisible()
|
||||
expect(state.starts).toBe(1)
|
||||
})
|
||||
|
||||
test("remote disclosure precedes authorization and all auth requests target that server", async ({ page }) => {
|
||||
const { state, dialog } = await fixture(page, true)
|
||||
await expect(dialog.getByRole("note")).toContainText("Connecting on “Production server”")
|
||||
await expect(dialog.getByRole("note")).toContainText("credentials will be stored on this server")
|
||||
expect(state.starts).toBe(0)
|
||||
const request = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && request.url().includes("/connect/oauth"),
|
||||
)
|
||||
await dialog.getByRole("button", { name: "Continue to OpenCode Console" }).click()
|
||||
expect(new URL((await request).url()).origin).toBe("http://production.example:4096")
|
||||
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
|
||||
const cancelled = page.waitForRequest(
|
||||
(request) => request.method() === "DELETE" && request.url().includes("/connect/oauth"),
|
||||
)
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).click()
|
||||
expect(new URL((await cancelled).url()).origin).toBe("http://production.example:4096")
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body class="overflow-hidden bg-v2-background-bg-deep">
|
||||
<div id="root" class="flex h-dvh flex-col p-px"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render } from "solid-js/web"
|
||||
import { MemoryRouter } from "@solidjs/router"
|
||||
import { AppBaseProviders, AppInterface } from "@/app"
|
||||
import { PlatformProvider } from "@/runtime/platform/platform"
|
||||
import { createBrowserDraftStore } from "@/runtime/persistence/drafts"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
|
||||
// Exercise the real Desktop renderer with local browser/clipboard adapters and
|
||||
// an HTTP fixture. No Electron service or account credentials are touched.
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const remote = params.get("server")
|
||||
const server: ServerConnection.Any = remote
|
||||
? { type: "http", displayName: "Production server", http: { url: remote } }
|
||||
: { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } }
|
||||
const root = document.getElementById("root")
|
||||
if (!root) throw new Error("Missing fixture root")
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider
|
||||
value={{
|
||||
platform: "desktop",
|
||||
windowID: "console-auth-fixture",
|
||||
os: "linux",
|
||||
draftStore: createBrowserDraftStore(),
|
||||
openExternal: () => {},
|
||||
restart: async () => {},
|
||||
notify: async () => {},
|
||||
openDirectoryPickerDialog: async () => null,
|
||||
writeClipboardText: (text) => navigator.clipboard.writeText(text),
|
||||
openBrowser: async (url) => {
|
||||
if (params.has("browserFailed")) return false
|
||||
const browser = window.open("about:blank", "_blank")
|
||||
if (!browser) return false
|
||||
browser.opener = null
|
||||
browser.location.replace(url)
|
||||
return true
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AppBaseProviders locale="en">
|
||||
<AppInterface servers={[server]} defaultServer={ServerConnection.key(server)} router={MemoryRouter} />
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
),
|
||||
root,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig, devices } from "@playwright/test"
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_PORT ?? 4454)
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
outputDir: "../test-results/desktop",
|
||||
timeout: 60000,
|
||||
expect: { timeout: 10000 },
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
use: { baseURL: `http://127.0.0.1:${port}`, screenshot: "only-on-failure", serviceWorkers: "block" },
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
webServer: {
|
||||
command: `bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`,
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
reuseExistingServer: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
})
|
||||
@@ -159,9 +159,9 @@ test.describe("session timeline projection", () => {
|
||||
await expect(shortNotice.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible()
|
||||
await expect(shortNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh")
|
||||
await expect(page.getByText("fast-nano", { exact: true })).toHaveCount(0)
|
||||
await expect(shortNotice.locator('[data-component="provider-icon"]')).toBeVisible()
|
||||
await expect(shortNotice.locator('[data-component="logo-mark"]')).toBeVisible()
|
||||
await expect(longNotice).toBeVisible()
|
||||
await expect(longNotice.locator('[data-component="provider-icon"]')).toBeVisible()
|
||||
await expect(longNotice.locator('[data-component="logo-mark"]')).toBeVisible()
|
||||
await expect(longNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
|
||||
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
|
||||
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
|
||||
@@ -19,6 +19,7 @@ const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testIgnore: [
|
||||
"desktop/**",
|
||||
"service-worker/**",
|
||||
process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
|
||||
],
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
import { useIntegrations } from "@/providers/catalog/integrations"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
@@ -152,6 +153,8 @@ export function NewSessionView(props: {
|
||||
</div>
|
||||
</div>
|
||||
<NewSessionTips
|
||||
selection={props.composer.model.selection}
|
||||
onDone={props.composer.restoreFocus}
|
||||
workspaceEligible={
|
||||
!!props.project.selected() &&
|
||||
props.workspace.bar.visible() &&
|
||||
@@ -165,13 +168,19 @@ export function NewSessionView(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
|
||||
function NewSessionTips(props: {
|
||||
selection: ComposerModel["model"]["selection"]
|
||||
onDone: () => void
|
||||
workspaceEligible: boolean
|
||||
onWorkspace: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const integrations = useIntegrations(() => sdk().directory)
|
||||
const [providerState, setProviderState, , providerReady] = persisted(
|
||||
Persist.global("new-session.provider-tip"),
|
||||
Persist.global("new-session.provider-tip-v3"),
|
||||
ProviderTipSchema,
|
||||
{ dismissedAt: 0 },
|
||||
)
|
||||
@@ -190,7 +199,10 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
|
||||
() =>
|
||||
providers.ready() &&
|
||||
providerReady() &&
|
||||
providers.paid().length === 0 &&
|
||||
!integrations.list().some((integration) => integration.connections.length > 0) &&
|
||||
!providers
|
||||
.connected()
|
||||
.some((provider) => provider.id !== "opencode" && Object.keys(provider.models).length > 0) &&
|
||||
Date.now() - providerState.dismissedAt >= providerTipDismissalDuration,
|
||||
)
|
||||
const tip = createMemo<"workspace" | "provider" | undefined>(() => {
|
||||
@@ -212,7 +224,11 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
|
||||
return
|
||||
}
|
||||
void import("@/providers/connect/dialog").then(({ DialogConnectProvider }) => {
|
||||
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
|
||||
void dialog.show(
|
||||
() => <DialogConnectProvider directory={sdk().directory} selection={props.selection} onDone={props.onDone} />,
|
||||
undefined,
|
||||
{ dismissOnBackdrop: false },
|
||||
)
|
||||
})
|
||||
}
|
||||
const dismiss = () => {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { consoleProviderGroup, consoleProviderName } from "./console"
|
||||
|
||||
test("groups only providers managed by the active Console workspace", () => {
|
||||
const direct = { id: "openai", integrationID: "openai", name: "Anomaly / OpenAI" }
|
||||
const group = consoleProviderGroup([
|
||||
{ id: "opencode", integrationID: "opencode", name: "Anomaly / OpenCode" },
|
||||
{ id: "console-openai", integrationID: "opencode", name: "Anomaly / OpenAI" },
|
||||
{ id: "console-google", integrationID: "opencode", name: "Anomaly / Google" },
|
||||
direct,
|
||||
])
|
||||
|
||||
expect(group).toBeDefined()
|
||||
if (!group) throw new Error("Expected Console provider group")
|
||||
expect(group.workspace).toBe("Anomaly")
|
||||
expect(group.providers.map((provider) => provider.id)).toEqual(["opencode", "console-openai", "console-google"])
|
||||
expect(consoleProviderName(group, group.providers[1].name)).toBe("OpenAI")
|
||||
expect(group.providers).not.toContain(direct)
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
type Provider = {
|
||||
id: string
|
||||
integrationID?: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function consoleProviderGroup<T extends Provider>(providers: readonly T[]) {
|
||||
const root = providers.find((provider) => provider.id === "opencode" && provider.integrationID === "opencode")
|
||||
const suffix = " / OpenCode"
|
||||
if (!root?.name.endsWith(suffix)) return
|
||||
const workspace = root.name.slice(0, -suffix.length).trim()
|
||||
if (!workspace) return
|
||||
const prefix = `${workspace} / `
|
||||
return {
|
||||
root,
|
||||
workspace,
|
||||
prefix,
|
||||
providers: providers.filter(
|
||||
(provider) => provider.integrationID === "opencode" && provider.name.startsWith(prefix),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function consoleProviderName(group: { prefix: string }, name: string) {
|
||||
return name.startsWith(group.prefix) ? name.slice(group.prefix.length) : name
|
||||
}
|
||||
@@ -45,6 +45,10 @@ export function useProviders(directory: Accessor<string | undefined>) {
|
||||
},
|
||||
all: () => providers().all,
|
||||
default: () => providers().default,
|
||||
usable: () =>
|
||||
(data.location.model.list(location()) ?? []).some(
|
||||
(model) => model.enabled && model.status !== "deprecated" && providers().connected.includes(model.providerID),
|
||||
),
|
||||
// V2 servers list only available providers, so the connectable catalog
|
||||
// comes from the integration list, with the provider catalog as fallback.
|
||||
popular: () => {
|
||||
@@ -72,15 +76,12 @@ export function useProviders(directory: Accessor<string | undefined>) {
|
||||
},
|
||||
paid: () => {
|
||||
const connected = new Set(providers().connected)
|
||||
const paid = [
|
||||
...Iterable.filter(
|
||||
providers().all,
|
||||
([id]) =>
|
||||
connected.has(id) &&
|
||||
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
|
||||
),
|
||||
]
|
||||
return paid
|
||||
const paid = new Set(
|
||||
(data.location.model.list(location()) ?? [])
|
||||
.filter((model) => model.enabled && model.cost.some((cost) => cost.input > 0))
|
||||
.map((model) => model.providerID),
|
||||
)
|
||||
return [...Iterable.filter(providers().all, ([id]) => connected.has(id) && paid.has(id))]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { TextShimmer } from "@opencode/ui/text-shimmer"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
export function ConsoleAuthorization(props: {
|
||||
code: string
|
||||
browserFailed: boolean
|
||||
copied: boolean
|
||||
copyFailed: boolean
|
||||
onOpen: () => void
|
||||
onCopy: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div
|
||||
data-component="console-authorization"
|
||||
class="flex flex-col gap-5 text-[13px] leading-5 text-v2-text-text-muted"
|
||||
>
|
||||
<p>
|
||||
{language.t(
|
||||
props.browserFailed ? "provider.connect.console.browserFailed" : "provider.connect.console.instructions",
|
||||
)}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-medium text-v2-text-text-base">{language.t("provider.connect.console.deviceCode")}</div>
|
||||
<div
|
||||
dir="ltr"
|
||||
role="group"
|
||||
aria-label={`${language.t("provider.connect.console.deviceCode")}: ${props.code}`}
|
||||
class="flex max-w-full gap-1 self-start font-mono text-xl font-[530] text-v2-text-text-base tabular-nums"
|
||||
>
|
||||
<For each={props.code.split("")}>
|
||||
{(character) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class={
|
||||
character === "-"
|
||||
? "mx-1 flex h-12 items-center text-v2-text-text-muted"
|
||||
: "flex h-12 w-8 items-center justify-center rounded-md border border-v2-border-border-base bg-v2-background-bg-layer-02"
|
||||
}
|
||||
>
|
||||
{character}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<p role="status">
|
||||
<TextShimmer text={language.t("provider.connect.console.waiting")} active />
|
||||
</p>
|
||||
</div>
|
||||
<div data-component="console-browser-fallback" class="flex min-h-7 flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span class="text-v2-text-text-faint">{language.t("provider.connect.console.browserHint")}</span>
|
||||
<Button variant="ghost-muted" onClick={props.onCopy}>
|
||||
{language.t(props.copied ? "provider.connect.console.linkCopied" : "provider.connect.console.copyLink")}
|
||||
</Button>
|
||||
<Show when={props.browserFailed || props.copyFailed}>
|
||||
<Button variant="ghost" onClick={props.onOpen}>
|
||||
{language.t("provider.connect.console.openAgain")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.copyFailed}>
|
||||
<p role="alert">{language.t("provider.connect.console.copyFailed")}</p>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,22 +9,41 @@ import { createStore, produce } from "solid-js/store"
|
||||
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||
type Authorization = IntegrationOauthConnectOutput["data"]
|
||||
|
||||
export function providerFormDefaults(fields: ProviderConnectMethod["form"]) {
|
||||
return (fields ?? []).reduce<FormAnswer>((answer, field) => {
|
||||
if (field.type === "external" || !field.hidden || field.default === undefined) return answer
|
||||
const active = (field.when ?? []).every((condition) => {
|
||||
const actual = answer[condition.key]
|
||||
if (actual === undefined) return false
|
||||
const equal = Array.isArray(actual)
|
||||
? typeof condition.value === "string" && actual.includes(condition.value)
|
||||
: actual === condition.value
|
||||
return condition.op === "eq" ? equal : !equal
|
||||
})
|
||||
if (!active) return answer
|
||||
return { ...answer, [field.key]: field.default }
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function createProviderConnectionController(options: {
|
||||
provider: () => string
|
||||
directory: () => string | undefined
|
||||
onComplete: () => void
|
||||
prepare?: (active: () => boolean) => Promise<boolean>
|
||||
initialMethod?: string
|
||||
pollInterval?: number
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const serverSDK = useServerSDK()
|
||||
const data = useData()
|
||||
const location = () => {
|
||||
const directory = options.directory()
|
||||
return directory ? { directory } : undefined
|
||||
}
|
||||
// An authorization belongs to the server and Location where it began.
|
||||
const directory = options.directory()
|
||||
const integrationID = options.provider()
|
||||
const desktopConsole = platform.platform === "desktop" && integrationID === "opencode"
|
||||
const location = () => (directory ? { directory } : undefined)
|
||||
const [integration] = createResource(
|
||||
() => ({ provider: options.provider(), directory: options.directory() }),
|
||||
() => ({ provider: integrationID, directory }),
|
||||
(input) =>
|
||||
serverSDK.api.integration
|
||||
.get({ integrationID: input.provider, location: location() })
|
||||
@@ -41,8 +60,12 @@ export function createProviderConnectionController(options: {
|
||||
methodIndex: undefined as number | undefined,
|
||||
authorization: undefined as Authorization | undefined,
|
||||
formAnswer: undefined as FormAnswer | undefined,
|
||||
state: "pending" as "pending" | "complete" | "error" | "form" | undefined,
|
||||
state: "pending" as "pending" | "waiting" | "refreshing" | "ready" | "error" | "form" | undefined,
|
||||
error: undefined as string | undefined,
|
||||
connected: false,
|
||||
browserFailed: false,
|
||||
statusFailed: false,
|
||||
selectingIndex: undefined as number | undefined,
|
||||
})
|
||||
const polling = {
|
||||
generation: 0,
|
||||
@@ -59,7 +82,7 @@ export function createProviderConnectionController(options: {
|
||||
| { type: "auth.form" }
|
||||
| { type: "auth.answer"; answer: FormAnswer | undefined }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: Authorization }
|
||||
| { type: "auth.authorized"; index: number; authorization: Authorization }
|
||||
| { type: "auth.error"; error: string }
|
||||
|
||||
const dispatch = (action: Action) => {
|
||||
@@ -71,6 +94,10 @@ export function createProviderConnectionController(options: {
|
||||
draft.formAnswer = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
draft.connected = false
|
||||
draft.browserFailed = false
|
||||
draft.statusFailed = false
|
||||
draft.selectingIndex = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "method.reset") {
|
||||
@@ -79,6 +106,10 @@ export function createProviderConnectionController(options: {
|
||||
draft.formAnswer = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
draft.connected = false
|
||||
draft.browserFailed = false
|
||||
draft.statusFailed = false
|
||||
draft.selectingIndex = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.form") {
|
||||
@@ -95,12 +126,15 @@ export function createProviderConnectionController(options: {
|
||||
if (action.type === "auth.pending") {
|
||||
draft.state = "pending"
|
||||
draft.error = undefined
|
||||
draft.selectingIndex = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.complete") {
|
||||
draft.state = "complete"
|
||||
if (action.type === "auth.authorized") {
|
||||
draft.methodIndex = action.index
|
||||
draft.state = "waiting"
|
||||
draft.authorization = action.authorization
|
||||
draft.error = undefined
|
||||
draft.selectingIndex = undefined
|
||||
return
|
||||
}
|
||||
draft.state = "error"
|
||||
@@ -115,24 +149,64 @@ export function createProviderConnectionController(options: {
|
||||
clearTimeout(polling.timer)
|
||||
polling.timer = undefined
|
||||
}
|
||||
const cancelAttempt = (authorization = store.authorization) => {
|
||||
if (!desktopConsole) return
|
||||
if (!authorization || (authorization.attemptID === store.authorization?.attemptID && store.connected)) return
|
||||
void serverSDK.api.integration.oauth
|
||||
.cancel({
|
||||
integrationID,
|
||||
attemptID: authorization.attemptID,
|
||||
location: location(),
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
const openBrowser = async () => {
|
||||
const authorization = store.authorization
|
||||
if (!authorization) return
|
||||
const generation = polling.generation
|
||||
const opened = await Promise.resolve()
|
||||
.then(async () => {
|
||||
if (platform.openBrowser) return platform.openBrowser(authorization.url)
|
||||
platform.openExternal(authorization.url)
|
||||
return true
|
||||
})
|
||||
.then((result) => result !== false)
|
||||
.catch(() => false)
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
setStore("browserFailed", !opened)
|
||||
}
|
||||
const finish = async () => {
|
||||
cancelPolling()
|
||||
const generation = polling.generation
|
||||
setStore({ connected: true, state: "refreshing", error: undefined })
|
||||
const ref = location()
|
||||
data.location.integration.invalidate(ref)
|
||||
data.location.provider.invalidate(ref)
|
||||
data.location.model.invalidate(ref)
|
||||
await Promise.all([
|
||||
const refreshed = await Promise.all([
|
||||
data.location.integration.sync(ref),
|
||||
data.location.provider.sync(ref),
|
||||
data.location.model.sync(ref),
|
||||
]).catch(() => undefined)
|
||||
if (polling.disposed) return
|
||||
])
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
const prepared =
|
||||
refreshed && options.prepare
|
||||
? await options.prepare(() => !polling.disposed && generation === polling.generation)
|
||||
: refreshed
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
if (!prepared && desktopConsole) {
|
||||
dispatch({ type: "auth.error", error: language.t("provider.connect.console.refreshFailed") })
|
||||
return
|
||||
}
|
||||
setStore("state", "ready")
|
||||
options.onComplete()
|
||||
}
|
||||
const poll = async (authorization: Authorization, generation: number) => {
|
||||
const result = await serverSDK.api.integration.oauth
|
||||
.status({
|
||||
integrationID: options.provider(),
|
||||
integrationID,
|
||||
attemptID: authorization.attemptID,
|
||||
location: location(),
|
||||
})
|
||||
@@ -140,9 +214,14 @@ export function createProviderConnectionController(options: {
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
if (!result.ok) {
|
||||
setStore("statusFailed", true)
|
||||
dispatch({
|
||||
type: "auth.error",
|
||||
error: result.error instanceof Error ? result.error.message : String(result.error),
|
||||
error: desktopConsole
|
||||
? language.t("provider.connect.console.statusFailed")
|
||||
: result.error instanceof Error
|
||||
? result.error.message
|
||||
: String(result.error),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -151,21 +230,41 @@ export function createProviderConnectionController(options: {
|
||||
return
|
||||
}
|
||||
if (result.status.status === "failed") {
|
||||
dispatch({ type: "auth.error", error: result.status.message })
|
||||
const message = result.status.message
|
||||
dispatch({
|
||||
type: "auth.error",
|
||||
error:
|
||||
desktopConsole && message.includes("expired_token")
|
||||
? language.t("provider.connect.console.expired")
|
||||
: desktopConsole && message.includes("access_denied")
|
||||
? language.t("provider.connect.console.denied")
|
||||
: message,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (result.status.status === "expired") {
|
||||
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
|
||||
dispatch({
|
||||
type: "auth.error",
|
||||
error: language.t(desktopConsole ? "provider.connect.console.expired" : "common.requestFailed"),
|
||||
})
|
||||
return
|
||||
}
|
||||
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
|
||||
polling.timer = setTimeout(
|
||||
() => void poll(authorization, generation),
|
||||
options.pollInterval ?? (desktopConsole ? 500 : 1_000),
|
||||
)
|
||||
}
|
||||
const select = async (index: number, answer?: FormAnswer) => {
|
||||
cancelPolling()
|
||||
cancelAttempt()
|
||||
const generation = polling.generation
|
||||
const selected = methods()[index]
|
||||
dispatch({ type: "method.select", index })
|
||||
if (selected.form?.length && !answer) {
|
||||
const defaults = providerFormDefaults(selected.form)
|
||||
const resolvedAnswer = answer ? { ...defaults, ...answer } : defaults
|
||||
const awaitAuthorization = desktopConsole && selected.type === "oauth" && selected.id === "device"
|
||||
if (!awaitAuthorization) dispatch({ type: "method.select", index })
|
||||
if (selected.form?.some((field) => field.type === "external" || !field.hidden) && !answer) {
|
||||
if (awaitAuthorization) dispatch({ type: "method.select", index })
|
||||
dispatch({ type: "auth.form" })
|
||||
return
|
||||
}
|
||||
@@ -175,41 +274,62 @@ export function createProviderConnectionController(options: {
|
||||
}
|
||||
if (selected.type !== "oauth") return
|
||||
if (selected.form?.some((field) => field.type !== "string")) {
|
||||
dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" })
|
||||
dispatch({ type: "auth.error", error: language.t("provider.connect.form.unsupported") })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.pending" })
|
||||
if (awaitAuthorization) {
|
||||
const retrying = store.state === "error"
|
||||
setStore({
|
||||
selectingIndex: index,
|
||||
authorization: undefined,
|
||||
...(retrying ? {} : { state: undefined, error: undefined }),
|
||||
browserFailed: false,
|
||||
statusFailed: false,
|
||||
})
|
||||
} else {
|
||||
dispatch({ type: "auth.pending" })
|
||||
}
|
||||
const result = await serverSDK.api.integration.oauth
|
||||
.connect({
|
||||
integrationID: options.provider(),
|
||||
integrationID,
|
||||
methodID: selected.id,
|
||||
...(answer ? { answer } : {}),
|
||||
...(Object.keys(resolvedAnswer).length > 0 ? { answer: resolvedAnswer } : {}),
|
||||
location: location(),
|
||||
})
|
||||
.then((response) => {
|
||||
if (options.provider() === "opencode" && platform.platform === "desktop") {
|
||||
if (integrationID === "opencode" && platform.platform === "desktop") {
|
||||
const url = new URL(response.data.url)
|
||||
url.searchParams.set("client_id", "opencode-desktop")
|
||||
url.searchParams.set("return_window", platform.windowID)
|
||||
response.data.url = url.href
|
||||
}
|
||||
return { ok: true as const, authorization: response.data }
|
||||
})
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
if (!result.ok) {
|
||||
dispatch({ type: "auth.error", error: String(result.error) })
|
||||
if (polling.disposed || generation !== polling.generation) {
|
||||
if (result.ok) cancelAttempt(result.authorization)
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.complete", authorization: result.authorization })
|
||||
if (!result.ok) {
|
||||
if (awaitAuthorization) dispatch({ type: "method.select", index })
|
||||
dispatch({
|
||||
type: "auth.error",
|
||||
error: desktopConsole ? language.t("provider.connect.console.startFailed") : String(result.error),
|
||||
})
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.authorized", index, authorization: result.authorization })
|
||||
if (desktopConsole && selected.id === "device") void openBrowser()
|
||||
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
|
||||
}
|
||||
const reset = () => {
|
||||
cancelPolling()
|
||||
cancelAttempt()
|
||||
dispatch({ type: "method.reset" })
|
||||
}
|
||||
const connectKey = async (key: string) => {
|
||||
await serverSDK.api.integration.connect.key({
|
||||
integrationID: options.provider(),
|
||||
integrationID,
|
||||
location: location(),
|
||||
key,
|
||||
...(store.formAnswer ? { answer: store.formAnswer } : {}),
|
||||
@@ -221,7 +341,7 @@ export function createProviderConnectionController(options: {
|
||||
if (!authorization) return language.t("provider.connect.oauth.code.invalid")
|
||||
const result = await serverSDK.api.integration.oauth
|
||||
.complete({
|
||||
integrationID: options.provider(),
|
||||
integrationID,
|
||||
attemptID: authorization.attemptID,
|
||||
location: location(),
|
||||
code,
|
||||
@@ -238,13 +358,20 @@ export function createProviderConnectionController(options: {
|
||||
|
||||
let auto = false
|
||||
createEffect(() => {
|
||||
if (auto || integration.loading || methods().length !== 1) return
|
||||
if (auto || integration.loading) return
|
||||
const index = options.initialMethod
|
||||
? methods().findIndex((method) => method.type === "oauth" && method.id === options.initialMethod)
|
||||
: methods().length === 1
|
||||
? 0
|
||||
: -1
|
||||
if (index < 0) return
|
||||
auto = true
|
||||
void select(0)
|
||||
void select(index)
|
||||
})
|
||||
onCleanup(() => {
|
||||
polling.disposed = true
|
||||
cancelPolling()
|
||||
cancelAttempt()
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -254,6 +381,9 @@ export function createProviderConnectionController(options: {
|
||||
currentMethod,
|
||||
methodIndex: () => store.methodIndex,
|
||||
authorization: () => store.authorization,
|
||||
browserFailed: () => store.browserFailed,
|
||||
selecting: (index: number) => store.selectingIndex === index,
|
||||
openBrowser,
|
||||
auth: {
|
||||
state: () => store.state,
|
||||
error: () => store.error,
|
||||
@@ -261,6 +391,15 @@ export function createProviderConnectionController(options: {
|
||||
reset,
|
||||
connectKey,
|
||||
completeCode,
|
||||
refresh: finish,
|
||||
retry: () => {
|
||||
if (store.connected) return finish()
|
||||
if (store.statusFailed && store.authorization) {
|
||||
setStore({ state: "waiting", error: undefined, statusFailed: false })
|
||||
return poll(store.authorization, polling.generation)
|
||||
}
|
||||
return store.methodIndex === undefined ? Promise.resolve() : select(store.methodIndex, store.formAnswer)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DialogConnectProvider, useProviderConnectController } from "./dialog"
|
||||
|
||||
function ConnectProviderDialogStory() {
|
||||
const dialog = useDialog()
|
||||
const open = () => dialog.show(() => <DialogConnectProvider />)
|
||||
const open = () => dialog.show(() => <DialogConnectProvider />, undefined, { dismissOnBackdrop: false })
|
||||
|
||||
onMount(open)
|
||||
|
||||
@@ -24,7 +24,8 @@ function ProviderConnectionDialogStory(props) {
|
||||
const dialog = useDialog()
|
||||
const controller = useProviderConnectController()
|
||||
controller.select(props.provider)
|
||||
const open = () => dialog.show(() => <DialogConnectProvider controller={controller} />)
|
||||
const open = () =>
|
||||
dialog.show(() => <DialogConnectProvider controller={controller} />, undefined, { dismissOnBackdrop: false })
|
||||
|
||||
onMount(open)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
.connected-model-row-shell {
|
||||
position: relative;
|
||||
margin-inline: 4px;
|
||||
padding-block: 4px;
|
||||
}
|
||||
|
||||
.connected-model-row-shell:not(:last-child)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline: 12px;
|
||||
bottom: 0;
|
||||
height: 0.5px;
|
||||
background: var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.connected-model-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
padding-inline: 12px;
|
||||
padding-block: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.connected-model-row:hover {
|
||||
background-color: var(--v2-background-bg-layer-03);
|
||||
}
|
||||
}
|
||||
|
||||
.connected-model-row:focus-visible {
|
||||
background-color: var(--v2-background-bg-layer-03);
|
||||
}
|
||||
|
||||
[data-component="first-provider-models"] [data-component="settings-list"] {
|
||||
background-color: var(--v2-background-bg-layer-02);
|
||||
overflow: hidden;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
[data-component="first-provider-models"] .provider-model-groups--dialog .provider-model-group {
|
||||
background-color: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
[data-component="first-provider-models"] .provider-model-groups--dialog .provider-model-group-models {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
[data-component="first-provider-models"]
|
||||
.provider-model-groups--dialog
|
||||
.provider-model-group-models
|
||||
> [data-component="settings-list"] {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
[data-component="first-provider-models"]
|
||||
.provider-model-groups--dialog
|
||||
.provider-model-group-trigger:not(:disabled):hover {
|
||||
background-color: var(--v2-background-bg-layer-03);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="first-provider-models"] .settings-section {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-component="first-provider-models"] .settings-section[data-expanded] {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { authServerName } from "./remote"
|
||||
|
||||
test("SSH disclosure uses the remote identity even with a loopback proxy", () => {
|
||||
expect(authServerName({ type: "ssh", host: "production.example", http: { url: "http://127.0.0.1:4096" } })).toBe(
|
||||
"production.example",
|
||||
)
|
||||
expect(
|
||||
authServerName({
|
||||
type: "ssh",
|
||||
host: "production.example",
|
||||
displayName: "Production server",
|
||||
http: { url: "http://127.0.0.1:4096" },
|
||||
}),
|
||||
).toBe("Production server")
|
||||
})
|
||||
|
||||
test("local Desktop and loopback HTTP connections do not show remote disclosure", () => {
|
||||
expect(authServerName({ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } })).toBeUndefined()
|
||||
for (const host of ["localhost", "127.0.0.1", "[::1]"]) {
|
||||
expect(authServerName({ type: "http", http: { url: `http://${host}:4096` } })).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test("WSL and remote HTTP connections show their server identity", () => {
|
||||
expect(
|
||||
authServerName({ type: "sidecar", variant: "wsl", distro: "Ubuntu", http: { url: "http://127.0.0.1:4096" } }),
|
||||
).toBe("Ubuntu")
|
||||
expect(authServerName({ type: "http", http: { url: "https://production.example" } })).toBe("production.example")
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection, serverName } from "@/runtime/server/registry"
|
||||
|
||||
export function authServerName(server: ServerConnection.Any) {
|
||||
if (ServerConnection.builtin(server)) return undefined
|
||||
if (server.type === "http" && ["localhost", "127.0.0.1", "[::1]"].includes(new URL(server.http.url).hostname))
|
||||
return undefined
|
||||
if (server.type === "sidecar" && server.variant === "wsl") return server.displayName ?? server.distro
|
||||
return serverName(server)
|
||||
}
|
||||
|
||||
export function RemoteAuthNotice(props: { server: ServerConnection.Any }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<Show when={authServerName(props.server)}>
|
||||
{(name) => (
|
||||
<div
|
||||
class="rounded-md border border-v2-border-border-base bg-v2-background-bg-layer-02 p-3 text-[13px] leading-5"
|
||||
role="note"
|
||||
>
|
||||
<p class="font-medium text-v2-text-text-base">
|
||||
{language.t("provider.connect.remote.title", { server: name() })}
|
||||
</p>
|
||||
<p class="text-v2-text-text-muted">{language.t("provider.connect.remote.description")}</p>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Badge } from "@opencode/ui/badge"
|
||||
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
@@ -6,7 +7,7 @@ import { ProviderIcon } from "@opencode/ui/provider-icon"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { useFilteredList } from "@opencode/ui/hooks"
|
||||
import { For, Show, type Component } from "solid-js"
|
||||
import { createMemo, For, Show, type Component } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/providers/models/selection"
|
||||
import { popularProviders } from "@/providers/catalog/providers"
|
||||
@@ -16,9 +17,19 @@ import { DialogConnectProvider } from "@/providers/connect/dialog"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
import { OpenCodeLogo } from "@/providers/opencode-logo"
|
||||
import { consoleProviderGroup, consoleProviderName } from "@/providers/catalog/console"
|
||||
import { ProviderModelGroup } from "@/providers/models/provider-group"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
type ModelItem = ReturnType<ReturnType<typeof useLocal>["model"]["list"]>[number]
|
||||
type ModelGroup = { category: string; items: ModelItem[] }
|
||||
type ConsoleGroup = NonNullable<ReturnType<typeof consoleProviderGroup<ModelItem["provider"]>>>
|
||||
type DisplayGroup =
|
||||
| { type: "provider"; group: ModelGroup }
|
||||
| { type: "console"; managed: ConsoleGroup; providers: ModelGroup[] }
|
||||
|
||||
const CONSOLE_GROUP_KEY = "console:opencode"
|
||||
|
||||
export const DialogManageModels: Component = () => {
|
||||
const local = useLocal()
|
||||
@@ -28,7 +39,9 @@ export const DialogManageModels: Component = () => {
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
|
||||
void dialog.show(() => <DialogConnectProvider directory={directory()} />, undefined, {
|
||||
dismissOnBackdrop: false,
|
||||
})
|
||||
}
|
||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||
const providerVisible = (providerID: string) =>
|
||||
@@ -54,9 +67,57 @@ export const DialogManageModels: Component = () => {
|
||||
const bPopular = bRank >= 0
|
||||
if (aPopular && !bPopular) return -1
|
||||
if (!aPopular && bPopular) return 1
|
||||
return aRank - bRank
|
||||
if (aPopular && bPopular) return aRank - bRank
|
||||
return a.items[0].provider.name.localeCompare(b.items[0].provider.name)
|
||||
},
|
||||
})
|
||||
const consoleGroup = createMemo(() =>
|
||||
consoleProviderGroup([...new Map(local.model.list().map((item) => [item.provider.id, item.provider])).values()]),
|
||||
)
|
||||
const groups = createMemo<DisplayGroup[]>(() => {
|
||||
const managed = consoleGroup()
|
||||
if (!managed) return list.grouped.latest.map((group) => ({ type: "provider" as const, group }))
|
||||
const ids = new Set(managed.providers.map((provider) => provider.id))
|
||||
const providers = list.grouped.latest.filter((group) => ids.has(group.category))
|
||||
if (providers.length === 0) return list.grouped.latest.map((group) => ({ type: "provider" as const, group }))
|
||||
const first = list.grouped.latest.findIndex((group) => ids.has(group.category))
|
||||
return list.grouped.latest.flatMap<DisplayGroup>((group, index) => {
|
||||
if (!ids.has(group.category)) return [{ type: "provider" as const, group }]
|
||||
if (index !== first) return []
|
||||
return [{ type: "console" as const, managed, providers }]
|
||||
})
|
||||
})
|
||||
const searching = () => list.filter().length > 0
|
||||
const expanded = (key: string) => searching() || !store.collapsed[key]
|
||||
const enabled = createMemo(() =>
|
||||
local.model.list().reduce((counts, item) => {
|
||||
if (!local.model.visible({ providerID: item.provider.id, modelID: item.id })) return counts
|
||||
counts.set(item.provider.id, (counts.get(item.provider.id) ?? 0) + 1)
|
||||
return counts
|
||||
}, new Map<string, number>()),
|
||||
)
|
||||
|
||||
function ModelRows(props: { items: ModelItem[] }) {
|
||||
return (
|
||||
<SettingsList variant="catalog">
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<SettingsRow title={item.name} description="">
|
||||
<div>
|
||||
<Switch
|
||||
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
|
||||
onChange={(checked) => setModelVisibility(item, checked)}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</For>
|
||||
</SettingsList>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog size="large" variant="settings" class="settings-manage-models-dialog">
|
||||
@@ -100,7 +161,7 @@ export const DialogManageModels: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="manage-models-scroll" class="relative min-h-0 flex-1">
|
||||
<div class="settings-panel settings-models h-full px-4 pt-4 pb-4">
|
||||
<div class="settings-panel settings-models h-full px-4 pt-1 pb-4">
|
||||
<Show
|
||||
when={!list.grouped.loading}
|
||||
fallback={
|
||||
@@ -121,68 +182,110 @@ export const DialogManageModels: Component = () => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={list.grouped.latest}>
|
||||
{(group) => {
|
||||
const searching = () => list.filter().length > 0
|
||||
const expanded = () => searching() || !store.collapsed[group.category]
|
||||
|
||||
return (
|
||||
<div
|
||||
class="settings-section"
|
||||
data-component="settings-models-provider"
|
||||
data-expanded={expanded() ? "" : undefined}
|
||||
>
|
||||
<div class="settings-models-group-header justify-between">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-models-group-trigger"
|
||||
aria-expanded={expanded()}
|
||||
disabled={searching()}
|
||||
onClick={() => setStore("collapsed", group.category, expanded())}
|
||||
>
|
||||
<span class="settings-models-group-chevron">
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
classList={{ "-rotate-90 rtl:rotate-90": !expanded() }}
|
||||
/>
|
||||
</span>
|
||||
<span class="settings-models-group-label">
|
||||
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
|
||||
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
|
||||
</span>
|
||||
</button>
|
||||
<Switch
|
||||
class="me-6"
|
||||
checked={providerVisible(group.category)}
|
||||
onChange={(checked) => setProviderVisibility(group.category, checked)}
|
||||
hideLabel
|
||||
>
|
||||
{group.items[0].provider.name}
|
||||
</Switch>
|
||||
</div>
|
||||
<Show when={expanded()}>
|
||||
<SettingsList variant="catalog">
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<SettingsRow title={item.name} description="">
|
||||
<div>
|
||||
<Switch
|
||||
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
|
||||
onChange={(checked) => setModelVisibility(item, checked)}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</For>
|
||||
</SettingsList>
|
||||
<For each={groups()}>
|
||||
{(item) => (
|
||||
<Show
|
||||
when={item.type === "console" ? item : undefined}
|
||||
fallback={
|
||||
<Show when={item.type === "provider" ? item.group : undefined}>
|
||||
{(group) => (
|
||||
<div
|
||||
class="settings-section"
|
||||
data-component="settings-models-provider"
|
||||
data-expanded={expanded(group().category) ? "" : undefined}
|
||||
>
|
||||
<div class="settings-models-group-header justify-between">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-models-group-trigger"
|
||||
aria-expanded={expanded(group().category)}
|
||||
disabled={searching()}
|
||||
onClick={() => setStore("collapsed", group().category, expanded(group().category))}
|
||||
>
|
||||
<span class="settings-models-group-chevron">
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
classList={{ collapsed: !expanded(group().category) }}
|
||||
/>
|
||||
</span>
|
||||
<span class="settings-models-group-label">
|
||||
<ProviderIcon id={group().category} width={16} height={16} class="shrink-0" />
|
||||
<bdi class="settings-models-group-title">{group().items[0].provider.name}</bdi>
|
||||
</span>
|
||||
</button>
|
||||
<Switch
|
||||
class="me-6"
|
||||
checked={providerVisible(group().category)}
|
||||
onChange={(checked) => setProviderVisibility(group().category, checked)}
|
||||
hideLabel
|
||||
>
|
||||
{group().items[0].provider.name}
|
||||
</Switch>
|
||||
</div>
|
||||
<Show when={expanded(group().category)}>
|
||||
<ModelRows items={group().items} />
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
}
|
||||
>
|
||||
{(console) => (
|
||||
<div
|
||||
class="settings-section settings-models-console"
|
||||
data-component="manage-models-console"
|
||||
data-expanded={expanded(CONSOLE_GROUP_KEY) ? "" : undefined}
|
||||
>
|
||||
<div class="settings-models-group-header">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-models-group-trigger"
|
||||
aria-expanded={expanded(CONSOLE_GROUP_KEY)}
|
||||
disabled={searching()}
|
||||
onClick={() => setStore("collapsed", CONSOLE_GROUP_KEY, expanded(CONSOLE_GROUP_KEY))}
|
||||
>
|
||||
<span class="settings-models-group-chevron">
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
classList={{ collapsed: !expanded(CONSOLE_GROUP_KEY) }}
|
||||
/>
|
||||
</span>
|
||||
<span class="settings-models-group-label">
|
||||
<OpenCodeLogo class="settings-models-provider-icon size-4 shrink-0" />
|
||||
<span class="settings-models-group-title">
|
||||
{language.t("provider.connect.opencode.name")}
|
||||
</span>
|
||||
<Badge>{console().managed.workspace}</Badge>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<Show when={expanded(CONSOLE_GROUP_KEY)}>
|
||||
<div class="provider-model-groups settings-models-console-groups">
|
||||
<For each={console().providers}>
|
||||
{(group) => {
|
||||
const count = () => enabled().get(group.category) ?? 0
|
||||
return (
|
||||
<ProviderModelGroup
|
||||
provider={group.items[0].provider}
|
||||
name={consoleProviderName(console().managed, group.items[0].provider.name)}
|
||||
expanded={expanded(group.category)}
|
||||
disabled={searching()}
|
||||
detail={language.plural("settings.models.enabled", count(), { count: count() })}
|
||||
onExpandedChange={(value) => setStore("collapsed", group.category, !value)}
|
||||
>
|
||||
<ModelRows items={group.items} />
|
||||
</ProviderModelGroup>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { iconNames, type IconName } from "@opencode/ui/icons/provider"
|
||||
import { ProviderIcon } from "@opencode/ui/provider-icon"
|
||||
import type { JSX } from "solid-js"
|
||||
import { Show } from "solid-js"
|
||||
import { OpenCodeLogo } from "@/providers/opencode-logo"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
export function ProviderModelGroup(props: {
|
||||
provider: { id: string; canonical?: string; name: string }
|
||||
name?: string
|
||||
expanded: boolean
|
||||
disabled?: boolean
|
||||
detail?: JSX.Element
|
||||
children: JSX.Element
|
||||
ref?: (element: HTMLElement) => void
|
||||
onExpandedChange: (expanded: boolean) => void
|
||||
}) {
|
||||
const icon = () =>
|
||||
[
|
||||
props.provider.canonical,
|
||||
props.provider.canonical?.replace(/-token-plan$/, ""),
|
||||
props.provider.id.replace(/^console-/, ""),
|
||||
].find((id): id is IconName => !!id && iconNames.includes(id as IconName)) ?? props.provider.id
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={props.ref}
|
||||
class="provider-model-group"
|
||||
data-component="provider-model-group"
|
||||
data-provider={props.provider.id}
|
||||
data-expanded={props.expanded ? "" : undefined}
|
||||
>
|
||||
<h3 class="provider-model-group-header">
|
||||
<button
|
||||
type="button"
|
||||
class="provider-model-group-trigger"
|
||||
aria-expanded={props.expanded}
|
||||
disabled={props.disabled}
|
||||
onClick={() => props.onExpandedChange(!props.expanded)}
|
||||
>
|
||||
<span class="provider-model-group-label">
|
||||
<Show
|
||||
when={props.provider.id === "opencode"}
|
||||
fallback={<ProviderIcon id={icon()} width={16} height={16} class="shrink-0" />}
|
||||
>
|
||||
<OpenCodeLogo class="size-4 shrink-0" />
|
||||
</Show>
|
||||
<bdi class="provider-model-group-title">{props.name ?? props.provider.name}</bdi>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
classList={{ "provider-model-group-chevron": true, collapsed: !props.expanded }}
|
||||
/>
|
||||
</span>
|
||||
<Show when={props.detail}>
|
||||
<span class="provider-model-group-detail">{props.detail}</span>
|
||||
</Show>
|
||||
</button>
|
||||
</h3>
|
||||
<Show when={props.expanded}>
|
||||
<div class="provider-model-group-models">{props.children}</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { popularProviders } from "@/providers/catalog/providers"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Badge } from "@opencode/ui/badge"
|
||||
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode/ui/dialog"
|
||||
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { ScrollView } from "@opencode/ui/scroll-view"
|
||||
@@ -22,6 +22,9 @@ import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { matchesModelSearch } from "./search"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { OpenCodeLogo } from "@/providers/opencode-logo"
|
||||
import { consoleProviderGroup, consoleProviderName } from "@/providers/catalog/console"
|
||||
import { ProviderModelGroup } from "@/providers/models/provider-group"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
@@ -29,9 +32,15 @@ const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
|
||||
type ModelState = ModelSelection
|
||||
type ModelItem = ReturnType<ModelState["list"]>[number]
|
||||
type ModelGroup = { category: string; items: ModelItem[] }
|
||||
type ConsoleGroup = NonNullable<ReturnType<typeof consoleProviderGroup<ModelItem["provider"]>>>
|
||||
type DisplayGroup =
|
||||
| { type: "provider"; group: ModelGroup }
|
||||
| { type: "console"; managed: ConsoleGroup; providers: ModelGroup[] }
|
||||
|
||||
const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}`
|
||||
const manageKey = "action:manage"
|
||||
const CONSOLE_GROUP_KEY = "console:opencode"
|
||||
|
||||
const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { category: string; items: ModelItem[] }) => {
|
||||
const aIndex = popularProviders.indexOf(a.category)
|
||||
@@ -62,9 +71,29 @@ const ModelList: Component<{
|
||||
collapsed: {} as Record<string, boolean>,
|
||||
})
|
||||
const models = createMemo(() => controller.models(store.search))
|
||||
const groups = createMemo(() => controller.groups(models()))
|
||||
const modelGroups = createMemo(() => controller.groups(models()))
|
||||
const consoleGroup = createMemo(() =>
|
||||
consoleProviderGroup([...new Map(controller.all().map((item) => [item.provider.id, item.provider])).values()]),
|
||||
)
|
||||
const groups = createMemo<DisplayGroup[]>(() => {
|
||||
const managed = consoleGroup()
|
||||
if (!managed) return modelGroups().map((group) => ({ type: "provider" as const, group }))
|
||||
const ids = new Set(managed.providers.map((provider) => provider.id))
|
||||
const providers = modelGroups().filter((group) => ids.has(group.category))
|
||||
if (providers.length === 0) return modelGroups().map((group) => ({ type: "provider" as const, group }))
|
||||
const first = modelGroups().findIndex((group) => ids.has(group.category))
|
||||
return modelGroups().flatMap<DisplayGroup>((group, index) => {
|
||||
if (!ids.has(group.category)) return [{ type: "provider" as const, group }]
|
||||
if (index !== first) return []
|
||||
return [{ type: "console" as const, managed, providers }]
|
||||
})
|
||||
})
|
||||
const expanded = (provider: string) => store.search.length > 0 || !store.collapsed[provider]
|
||||
const visibleModels = () => models().filter((item) => expanded(item.provider.id))
|
||||
const managedIDs = createMemo(() => new Set(consoleGroup()?.providers.map((provider) => provider.id) ?? []))
|
||||
const visibleModels = () =>
|
||||
models().filter(
|
||||
(item) => expanded(item.provider.id) && (!managedIDs().has(item.provider.id) || expanded(CONSOLE_GROUP_KEY)),
|
||||
)
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
|
||||
const setSearch = (value: string) => {
|
||||
@@ -88,6 +117,53 @@ const ModelList: Component<{
|
||||
if (item) controller.select(item)
|
||||
}
|
||||
|
||||
function ModelRows(props: { items: ModelItem[] }) {
|
||||
return (
|
||||
<SettingsList variant="catalog">
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
data-component="settings-row"
|
||||
data-option-key={modelKey(item)}
|
||||
class="-mx-4 w-[calc(100%+32px)] px-4 text-start first:rounded-t-lg last:rounded-b-lg hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||
onMouseEnter={() => setStore("active", modelKey(item))}
|
||||
onMouseLeave={() => setStore("active", "")}
|
||||
onClick={() => controller.select(item)}
|
||||
>
|
||||
<div data-slot="settings-row-copy">
|
||||
<div data-slot="settings-row-title" class="flex items-center gap-2">
|
||||
<Tooltip
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
openDelay={0}
|
||||
value={
|
||||
<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} v2 />
|
||||
}
|
||||
>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
</Tooltip>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.latest")}</Badge>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="settings-row-control" class="size-4">
|
||||
<Show when={controller.current() === modelKey(item)}>
|
||||
<Icon name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</SettingsList>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="shrink-0 px-4 pt-px pb-3">
|
||||
@@ -137,87 +213,98 @@ const ModelList: Component<{
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div ref={(element) => (scrollRef = element)} class="settings-panel settings-models h-full px-4 pt-4 pb-4">
|
||||
<div ref={(element) => (scrollRef = element)} class="settings-panel settings-models h-full px-4 pt-1 pb-4">
|
||||
<Show
|
||||
when={models().length > 0}
|
||||
fallback={<div class="settings-models-status">{language.t("dialog.model.empty")}</div>}
|
||||
>
|
||||
<For each={groups()}>
|
||||
{(group) => {
|
||||
const searching = () => store.search.length > 0
|
||||
const open = () => expanded(group.category)
|
||||
|
||||
return (
|
||||
<section class="settings-section" data-expanded={open() ? "" : undefined}>
|
||||
<h3 class="settings-models-group-header">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-models-group-trigger"
|
||||
aria-expanded={open()}
|
||||
disabled={searching()}
|
||||
onClick={() => setStore("collapsed", group.category, open())}
|
||||
>
|
||||
<span class="settings-models-group-chevron">
|
||||
<Icon name="chevron-down" size="small" classList={{ "-rotate-90 rtl:rotate-90": !open() }} />
|
||||
</span>
|
||||
<span class="settings-models-group-label">
|
||||
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
|
||||
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<Show when={open()}>
|
||||
<SettingsList variant="catalog">
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
data-component="settings-row"
|
||||
data-option-key={modelKey(item)}
|
||||
class="-mx-4 w-[calc(100%+32px)] px-4 text-start first:rounded-t-lg last:rounded-b-lg hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||
onMouseEnter={() => setStore("active", modelKey(item))}
|
||||
onMouseLeave={() => setStore("active", "")}
|
||||
onClick={() => controller.select(item)}
|
||||
>
|
||||
<div data-slot="settings-row-copy">
|
||||
<div data-slot="settings-row-title" class="flex items-center gap-2">
|
||||
<Tooltip
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
openDelay={0}
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
latest={item.latest}
|
||||
free={isFree(item.provider.id, item.cost)}
|
||||
v2
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
</Tooltip>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.latest")}</Badge>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="settings-row-control" class="size-4">
|
||||
<Show when={controller.current() === modelKey(item)}>
|
||||
<Icon name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</SettingsList>
|
||||
{(item) => (
|
||||
<Show
|
||||
when={item.type === "console" ? item : undefined}
|
||||
fallback={
|
||||
<Show when={item.type === "provider" ? item.group : undefined}>
|
||||
{(group) => {
|
||||
const open = () => expanded(group().category)
|
||||
return (
|
||||
<section class="settings-section" data-expanded={open() ? "" : undefined}>
|
||||
<h3 class="settings-models-group-header">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-models-group-trigger"
|
||||
aria-expanded={open()}
|
||||
disabled={store.search.length > 0}
|
||||
onClick={() => setStore("collapsed", group().category, open())}
|
||||
>
|
||||
<span class="settings-models-group-chevron">
|
||||
<Icon name="chevron-down" size="small" classList={{ collapsed: !open() }} />
|
||||
</span>
|
||||
<span class="settings-models-group-label">
|
||||
<ProviderIcon id={group().category} width={16} height={16} class="shrink-0" />
|
||||
<bdi class="settings-models-group-title">{group().items[0].provider.name}</bdi>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<Show when={open()}>
|
||||
<ModelRows items={group().items} />
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}}
|
||||
}
|
||||
>
|
||||
{(console) => (
|
||||
<section
|
||||
class="settings-section settings-models-console"
|
||||
data-component="select-model-console"
|
||||
data-expanded={expanded(CONSOLE_GROUP_KEY) ? "" : undefined}
|
||||
>
|
||||
<h3 class="settings-models-group-header">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-models-group-trigger"
|
||||
aria-expanded={expanded(CONSOLE_GROUP_KEY)}
|
||||
disabled={store.search.length > 0}
|
||||
onClick={() => setStore("collapsed", CONSOLE_GROUP_KEY, expanded(CONSOLE_GROUP_KEY))}
|
||||
>
|
||||
<span class="settings-models-group-chevron">
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
classList={{ collapsed: !expanded(CONSOLE_GROUP_KEY) }}
|
||||
/>
|
||||
</span>
|
||||
<span class="settings-models-group-label">
|
||||
<OpenCodeLogo class="settings-models-provider-icon size-4 shrink-0" />
|
||||
<span class="settings-models-group-title">
|
||||
{language.t("provider.connect.opencode.name")}
|
||||
</span>
|
||||
<Badge>{console().managed.workspace}</Badge>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<Show when={expanded(CONSOLE_GROUP_KEY)}>
|
||||
<div class="provider-model-groups settings-models-console-groups">
|
||||
<For each={console().providers}>
|
||||
{(group) => (
|
||||
<ProviderModelGroup
|
||||
provider={group.items[0].provider}
|
||||
name={consoleProviderName(console().managed, group.items[0].provider.name)}
|
||||
expanded={expanded(group.category)}
|
||||
disabled={store.search.length > 0}
|
||||
onExpandedChange={(value) => setStore("collapsed", group.category, !value)}
|
||||
>
|
||||
<ModelRows items={group.items} />
|
||||
</ProviderModelGroup>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -272,6 +359,7 @@ function createModelSelectorController(input: {
|
||||
)
|
||||
|
||||
return {
|
||||
all: () => model.list().filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
|
||||
models: (search: string) => {
|
||||
const query = search.trim()
|
||||
const filtered = query
|
||||
@@ -535,7 +623,9 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
|
||||
|
||||
const provider = () => {
|
||||
void import("@/providers/connect/dialog").then((x) => {
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />, undefined, {
|
||||
dismissOnBackdrop: false,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -548,7 +638,7 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
|
||||
return (
|
||||
<Dialog size="large" variant="settings">
|
||||
<DialogHeader hideClose closeLabel={language.t("common.close")}>
|
||||
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
||||
<DialogTitleGroup title={language.t("dialog.model.select.title")} />
|
||||
<Button icon="plus" onClick={provider}>
|
||||
{language.t("command.provider.connect")}
|
||||
</Button>
|
||||
|
||||
@@ -37,7 +37,9 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
void import("@/providers/connect/dialog").then((x) => {
|
||||
const controller = x.useProviderConnectController()
|
||||
controller.select(provider)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />, undefined, {
|
||||
dismissOnBackdrop: false,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export function OpenCodeLogo(props: { class?: string }) {
|
||||
return (
|
||||
<svg
|
||||
data-component="opencode-logo"
|
||||
aria-hidden="true"
|
||||
class={props.class}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g transform="translate(1.2 1.2) scale(0.85)">
|
||||
<path opacity="0.2" d="M11.1999 12.8H4.79993V6.40002H11.1999V12.8Z" fill="currentColor" />
|
||||
<path d="M11.2 3.2H4.79998V12.8H11.2V3.2ZM14.4 16H1.59998V0H14.4V16Z" fill="currentColor" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -192,6 +192,53 @@ export const dict = {
|
||||
"dialog.provider.viewAll": "Show more providers",
|
||||
|
||||
"provider.connect.title": "Connect {{provider}}",
|
||||
"provider.connect.opencode.name": "OpenCode",
|
||||
"provider.connect.opencode.errorTitle": "Connect to OpenCode",
|
||||
"provider.connect.console.title": "Connect OpenCode Console account",
|
||||
"provider.connect.console.name": "OpenCode Console",
|
||||
"provider.connect.console.instructions":
|
||||
"Continue in your browser. Confirm the code shown there matches the one below.",
|
||||
"provider.connect.console.deviceCode": "Device code",
|
||||
"provider.connect.console.waiting": "Waiting for confirmation…",
|
||||
"provider.connect.console.browserHint": "Browser didn't open?",
|
||||
"provider.connect.console.copyLink": "Copy sign-in link",
|
||||
"provider.connect.console.linkCopied": "Sign-in link copied",
|
||||
"provider.connect.console.copyFailed": "Couldn't copy the sign-in link. Open Console again to continue.",
|
||||
"provider.connect.console.openAgain": "Open Console again",
|
||||
"provider.connect.console.browserFailed":
|
||||
"We couldn't open your browser. Try again or copy the sign-in link to continue.",
|
||||
"provider.connect.console.expired": "This sign-in request has expired. Start again to get a new device code.",
|
||||
"provider.connect.console.denied": "Access was denied in Console. Try again when you're ready to connect.",
|
||||
"provider.connect.console.statusFailed": "Couldn't check authorization. Check your server connection and try again.",
|
||||
"provider.connect.console.startFailed": "Couldn't start sign-in. Check your server connection and try again.",
|
||||
"provider.connect.models.title": "Connected to {{provider}}",
|
||||
"provider.connect.models.description": "Choose a model to start with. You can switch models anytime.",
|
||||
"provider.connect.models.available": "Available models",
|
||||
"provider.connect.models.list": "Models available from {{provider}}",
|
||||
"provider.connect.console.retry": "Try again",
|
||||
"provider.connect.console.refreshing": "OpenCode connected. Loading your models...",
|
||||
"provider.connect.console.refreshFailed":
|
||||
"Your account is connected, but we couldn't load your models. Try again to refresh them.",
|
||||
"provider.connect.console.connected": "OpenCode connected",
|
||||
"provider.connect.console.ready": "Your models are ready.",
|
||||
"provider.connect.console.noModels":
|
||||
"Your account is connected, but this Console workspace has no available models. Check its setup in Console, then refresh.",
|
||||
"provider.connect.console.refresh": "Refresh models",
|
||||
"provider.connect.console.model": "Model",
|
||||
"provider.connect.console.start": "Start coding",
|
||||
"provider.connect.console.done": "Done",
|
||||
"provider.connect.console.continue": "Continue to OpenCode Console",
|
||||
"provider.connect.console.openingBrowser": "Opening browser…",
|
||||
"provider.connect.console.serviceAccount": "Service account?",
|
||||
"provider.connect.console.useApiKey": "Use API key",
|
||||
"provider.connect.console.otherMethods": "Other methods",
|
||||
"provider.connect.console.serviceKey": "API key (service account)",
|
||||
"provider.connect.console.serviceKeyDescription": "Connect using a service-account API key from OpenCode Console.",
|
||||
"provider.connect.console.intro": "Sign in with your OpenCode Console account to use the available models.",
|
||||
"provider.connect.remote.title": "Connecting on “{{server}}”",
|
||||
"provider.connect.remote.description":
|
||||
"Your OpenCode credentials will be stored on this server. Models will be available through this server.",
|
||||
"provider.connect.form.unsupported": "This authentication form contains unsupported fields",
|
||||
"provider.connect.title.anthropicProMax": "Login with Anthropic",
|
||||
"provider.connect.selectMethod": "Select login method for {{provider}}.",
|
||||
"provider.connect.method.apiKey": "API key",
|
||||
@@ -1330,6 +1377,8 @@ export const dict = {
|
||||
"settings.providers.section.connected": "Connected providers",
|
||||
"settings.providers.connected.empty": "No connected providers",
|
||||
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
|
||||
"settings.providers.console.available.one": "{{count}} provider available",
|
||||
"settings.providers.console.available.other": "{{count}} providers available",
|
||||
"settings.providers.section.popular": "Popular providers",
|
||||
"settings.providers.custom.description": "Add an OpenAI-compatible provider by base URL.",
|
||||
"settings.providers.tag.environment": "Environment",
|
||||
@@ -1338,6 +1387,8 @@ export const dict = {
|
||||
"settings.providers.tag.other": "Other",
|
||||
"settings.models.title": "Models",
|
||||
"settings.models.description": "Choose which models appear in model picker",
|
||||
"settings.models.enabled.one": "{{count}} model enabled",
|
||||
"settings.models.enabled.other": "{{count}} models enabled",
|
||||
"settings.agents.title": "Agents",
|
||||
"settings.agents.description": "Agent settings will be configurable here.",
|
||||
"settings.commands.title": "Commands",
|
||||
|
||||
@@ -37,6 +37,9 @@ type PlatformBase = {
|
||||
/** Open a web or mail URL in the default system application */
|
||||
openExternal(url: string): void
|
||||
|
||||
/** Open an authentication page, reporting whether the browser could be launched. */
|
||||
openBrowser?(url: string): Promise<boolean>
|
||||
|
||||
/** Open a local path in a local app (desktop only) */
|
||||
openPath?(path: string, app?: string): Promise<void>
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ export function normalizeProviderList(
|
||||
for (const provider of providers) {
|
||||
all.set(provider.id, {
|
||||
id: provider.id,
|
||||
canonical: provider.canonical,
|
||||
integrationID: provider.integrationID,
|
||||
name: provider.name,
|
||||
source: "custom",
|
||||
env: [],
|
||||
|
||||
@@ -127,6 +127,8 @@ export type Model = {
|
||||
|
||||
export type Provider = {
|
||||
id: string
|
||||
canonical?: string
|
||||
integrationID?: string
|
||||
name: string
|
||||
source: "env" | "config" | "custom" | "api"
|
||||
env: string[]
|
||||
|
||||
@@ -83,7 +83,9 @@ export function useUsageExceededDialogs() {
|
||||
void import("@/providers/connect/dialog").then((x) => {
|
||||
const controller = x.useProviderConnectController()
|
||||
controller.select("opencode-go")
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} />)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} />, undefined, {
|
||||
dismissOnBackdrop: false,
|
||||
})
|
||||
})
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useFilteredList } from "@opencode/ui/hooks"
|
||||
import { Badge } from "@opencode/ui/badge"
|
||||
import { ProviderIcon } from "@opencode/ui/provider-icon"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { type Component, createEffect, For, on, onCleanup, Show } from "solid-js"
|
||||
import { type Component, createEffect, createMemo, For, on, onCleanup, Show } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -14,17 +15,31 @@ import { popularProviders } from "@/providers/catalog/providers"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
import { OpenCodeLogo } from "@/providers/opencode-logo"
|
||||
import { consoleProviderGroup, consoleProviderName } from "@/providers/catalog/console"
|
||||
import { ProviderModelGroup } from "@/providers/models/provider-group"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
||||
type ModelGroup = { category: string; items: ModelItem[] }
|
||||
type ConsoleGroup = NonNullable<ReturnType<typeof consoleProviderGroup<ModelItem["provider"]>>>
|
||||
type DisplayGroup =
|
||||
| { type: "provider"; group: ModelGroup }
|
||||
| { type: "console"; managed: ConsoleGroup; providers: ModelGroup[] }
|
||||
|
||||
const PROVIDER_ICON_SIZE = 16
|
||||
const CONSOLE_GROUP_KEY = "console:opencode"
|
||||
|
||||
export const ModelProvidersSchema = Schema.Struct({
|
||||
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
|
||||
})
|
||||
|
||||
export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }> = (props) => {
|
||||
export const SettingsModels: Component<{
|
||||
active?: boolean
|
||||
autofocus?: boolean
|
||||
provider?: string
|
||||
onReveal?: () => void
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const models = useModels()
|
||||
const serverSdk = useServerSDK()
|
||||
@@ -47,6 +62,7 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
|
||||
ModelProvidersSchema,
|
||||
{ collapsed: {} },
|
||||
)
|
||||
const sections = new Map<string, HTMLElement>()
|
||||
|
||||
const list = useFilteredList<ModelItem>({
|
||||
items: (_filter) => models.list(),
|
||||
@@ -69,6 +85,86 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
|
||||
return aName.localeCompare(bName)
|
||||
},
|
||||
})
|
||||
const consoleGroup = createMemo(() =>
|
||||
consoleProviderGroup([...new Map(models.list().map((item) => [item.provider.id, item.provider])).values()]),
|
||||
)
|
||||
const groups = createMemo<DisplayGroup[]>(() => {
|
||||
const managed = consoleGroup()
|
||||
if (!managed) return list.grouped.latest.map((group) => ({ type: "provider" as const, group }))
|
||||
const ids = new Set(managed.providers.map((provider) => provider.id))
|
||||
const providers = list.grouped.latest.filter((group) => ids.has(group.category))
|
||||
if (providers.length === 0) return list.grouped.latest.map((group) => ({ type: "provider" as const, group }))
|
||||
const first = list.grouped.latest.findIndex((group) => ids.has(group.category))
|
||||
return list.grouped.latest.flatMap<DisplayGroup>((group, index) => {
|
||||
if (!ids.has(group.category)) return [{ type: "provider" as const, group }]
|
||||
if (index !== first) return []
|
||||
return [{ type: "console" as const, managed, providers }]
|
||||
})
|
||||
})
|
||||
const searching = () => list.filter().length > 0
|
||||
const expanded = (key: string) => searching() || !store.collapsed[key]
|
||||
const enabled = createMemo(() =>
|
||||
models.list().reduce((counts, item) => {
|
||||
if (!models.visible({ providerID: item.provider.id, modelID: item.id })) return counts
|
||||
counts.set(item.provider.id, (counts.get(item.provider.id) ?? 0) + 1)
|
||||
return counts
|
||||
}, new Map<string, number>()),
|
||||
)
|
||||
|
||||
function ModelRows(props: { items: ModelItem[] }) {
|
||||
return (
|
||||
<SettingsList variant="catalog">
|
||||
<For each={props.items}>
|
||||
{(item) => {
|
||||
const key = { providerID: item.provider.id, modelID: item.id }
|
||||
return (
|
||||
<SettingsRow title={item.name} description="">
|
||||
<div>
|
||||
<Switch
|
||||
checked={models.visible(key)}
|
||||
onChange={(checked) => models.setVisibility(key, checked)}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SettingsList>
|
||||
)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.active || !props.provider) return
|
||||
const provider = props.provider
|
||||
if (list.filter()) {
|
||||
list.clear()
|
||||
return
|
||||
}
|
||||
if (!list.grouped.latest.some((group) => group.category === provider)) return
|
||||
const section = sections.get(provider)
|
||||
if (!section?.isConnected) return
|
||||
const managed = consoleGroup()?.providers.some((item) => item.id === provider)
|
||||
setStore("collapsed", CONSOLE_GROUP_KEY, Boolean(!managed))
|
||||
list.grouped.latest.forEach((group) => setStore("collapsed", group.category, group.category !== provider))
|
||||
requestAnimationFrame(() => {
|
||||
const panel = section.closest<HTMLElement>(".settings-panel")
|
||||
const header = panel?.querySelector<HTMLElement>(".settings-tab-header")
|
||||
if (panel && header) {
|
||||
panel.scrollTo({
|
||||
top: panel.scrollTop + section.getBoundingClientRect().top - header.getBoundingClientRect().bottom - 24,
|
||||
})
|
||||
} else {
|
||||
section.scrollIntoView({ block: "start" })
|
||||
}
|
||||
section
|
||||
.querySelector<HTMLElement>(".provider-model-group-trigger, .settings-models-group-trigger")
|
||||
?.focus({ preventScroll: true })
|
||||
props.onReveal?.()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -127,83 +223,109 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={list.grouped.latest}>
|
||||
{(group) => {
|
||||
const searching = () => list.filter().length > 0
|
||||
const expanded = () => searching() || !store.collapsed[group.category]
|
||||
|
||||
return (
|
||||
<div
|
||||
class="settings-section"
|
||||
data-component="settings-models-provider"
|
||||
data-expanded={expanded() ? "" : undefined}
|
||||
>
|
||||
<h3 class="settings-models-group-header">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-models-group-trigger"
|
||||
aria-expanded={expanded()}
|
||||
disabled={searching()}
|
||||
onClick={() => setStore("collapsed", group.category, expanded())}
|
||||
>
|
||||
<span class="settings-models-group-chevron">
|
||||
<Show
|
||||
when={expanded()}
|
||||
fallback={
|
||||
<svg width="5" height="6" viewBox="0 0 5 6" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M0.75194 5.31663C0.41861 5.51103 0 5.27063 0 4.88473V0.500754C0 0.114854 0.41861 -0.125577 0.75194 0.0688635L4.5096 2.26084C4.8404 2.45378 4.8404 2.93168 4.5096 3.12462L0.75194 5.31663Z"
|
||||
fill="currentColor"
|
||||
<For each={groups()}>
|
||||
{(item) => (
|
||||
<Show
|
||||
when={item.type === "console" ? item : undefined}
|
||||
fallback={
|
||||
<Show when={item.type === "provider" ? item.group : undefined}>
|
||||
{(group) => (
|
||||
<div
|
||||
ref={(element) => sections.set(group().category, element)}
|
||||
class="settings-section"
|
||||
data-component="settings-models-provider"
|
||||
data-expanded={expanded(group().category) ? "" : undefined}
|
||||
>
|
||||
<h3 class="settings-models-group-header">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-models-group-trigger"
|
||||
aria-expanded={expanded(group().category)}
|
||||
disabled={searching()}
|
||||
onClick={() => setStore("collapsed", group().category, expanded(group().category))}
|
||||
>
|
||||
<span class="settings-models-group-chevron">
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
classList={{ collapsed: !expanded(group().category) }}
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="settings-models-group-label">
|
||||
<ProviderIcon
|
||||
id={group().category}
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-models-provider-icon shrink-0"
|
||||
/>
|
||||
<bdi class="settings-models-group-title">{group().items[0].provider.name}</bdi>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<Show when={expanded(group().category)}>
|
||||
<ModelRows items={group().items} />
|
||||
</Show>
|
||||
</span>
|
||||
<span class="settings-models-group-label">
|
||||
<ProviderIcon
|
||||
id={group.category}
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-models-provider-icon shrink-0"
|
||||
/>
|
||||
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<Show when={expanded()}>
|
||||
<SettingsList variant="catalog">
|
||||
<For each={group.items}>
|
||||
{(item) => {
|
||||
const key = { providerID: item.provider.id, modelID: item.id }
|
||||
return (
|
||||
<SettingsRow title={item.name} description="">
|
||||
<div>
|
||||
<Switch
|
||||
checked={models.visible(key)}
|
||||
onChange={(checked) => {
|
||||
models.setVisibility(key, checked)
|
||||
}}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
}
|
||||
>
|
||||
{(console) => (
|
||||
<div
|
||||
class="settings-section settings-models-console"
|
||||
data-component="settings-models-console"
|
||||
data-expanded={expanded(CONSOLE_GROUP_KEY) ? "" : undefined}
|
||||
>
|
||||
<h3 class="settings-models-group-header">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-models-group-trigger"
|
||||
aria-expanded={expanded(CONSOLE_GROUP_KEY)}
|
||||
disabled={searching()}
|
||||
onClick={() => setStore("collapsed", CONSOLE_GROUP_KEY, expanded(CONSOLE_GROUP_KEY))}
|
||||
>
|
||||
<span class="settings-models-group-chevron">
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
classList={{ collapsed: !expanded(CONSOLE_GROUP_KEY) }}
|
||||
/>
|
||||
</span>
|
||||
<span class="settings-models-group-label">
|
||||
<OpenCodeLogo class="settings-models-provider-icon size-4 shrink-0" />
|
||||
<span class="settings-models-group-title">
|
||||
{language.t("provider.connect.opencode.name")}
|
||||
</span>
|
||||
<Badge>{console().managed.workspace}</Badge>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<Show when={expanded(CONSOLE_GROUP_KEY)}>
|
||||
<div class="provider-model-groups settings-models-console-groups">
|
||||
<For each={console().providers}>
|
||||
{(group) => {
|
||||
const count = () => enabled().get(group.category) ?? 0
|
||||
return (
|
||||
<ProviderModelGroup
|
||||
ref={(element) => sections.set(group.category, element)}
|
||||
provider={group.items[0].provider}
|
||||
name={consoleProviderName(console().managed, group.items[0].provider.name)}
|
||||
expanded={expanded(group.category)}
|
||||
disabled={searching()}
|
||||
detail={language.plural("settings.models.enabled", count(), { count: count() })}
|
||||
onExpandedChange={(value) => setStore("collapsed", group.category, !value)}
|
||||
>
|
||||
<ModelRows items={group.items} />
|
||||
</ProviderModelGroup>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Badge } from "@opencode/ui/badge"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { ProviderIcon } from "@opencode/ui/provider-icon"
|
||||
import { OpenCodeLogo } from "@/providers/opencode-logo"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { popularProviders, useProviders } from "@/providers/catalog/providers"
|
||||
import { consoleProviderGroup } from "@/providers/catalog/console"
|
||||
import { useIntegrations } from "@/providers/catalog/integrations"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { createEffect, createMemo, type Component, For, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import "@/settings/settings.css"
|
||||
@@ -31,30 +36,104 @@ const PROVIDER_ICON_SIZE = 16
|
||||
export const SettingsProviders: Component<{
|
||||
directory: string | undefined
|
||||
onBack?: () => void
|
||||
onSelectProvider?: (providerID: string) => void
|
||||
}> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const data = useData()
|
||||
const providers = useProviders(() => props.directory)
|
||||
const integrations = useIntegrations(() => props.directory)
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
const [state, setState] = createStore({
|
||||
disconnecting: {} as Record<string, "removing" | "removed" | "absent" | undefined>,
|
||||
consoleExpanded: false,
|
||||
connecting: false,
|
||||
})
|
||||
const updateDisconnecting = (ids: string[], status: "removing" | "removed" | "absent" | undefined) =>
|
||||
setState("disconnecting", (current) => ({
|
||||
...current,
|
||||
...Object.fromEntries(ids.map((id) => [id, status])),
|
||||
}))
|
||||
const integration = (providerID: string) => integrations.list().find((item) => item.id === providerID)
|
||||
|
||||
const connect = (provider?: string) => {
|
||||
setState("connecting", true)
|
||||
providerConnect.select(provider)
|
||||
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
|
||||
void dialog.show(
|
||||
() => (
|
||||
<DialogConnectProvider
|
||||
directory={props.directory}
|
||||
defaultLocation={props.directory === undefined}
|
||||
controller={providerConnect}
|
||||
onConnected={(providerID) => {
|
||||
if (providerID === "opencode") {
|
||||
setState("disconnecting", reconcile({}))
|
||||
return
|
||||
}
|
||||
setState("disconnecting", providerID, undefined)
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => {
|
||||
setState("connecting", false)
|
||||
const location = props.directory ? { directory: props.directory } : undefined
|
||||
data.location.integration.invalidate(location)
|
||||
data.location.provider.invalidate(location)
|
||||
data.location.model.invalidate(location)
|
||||
void Promise.all([
|
||||
data.location.integration.sync(location),
|
||||
data.location.provider.sync(location),
|
||||
data.location.model.sync(location),
|
||||
]).catch(() => undefined)
|
||||
},
|
||||
{ dismissOnBackdrop: false },
|
||||
)
|
||||
}
|
||||
|
||||
const connected = createMemo(() => {
|
||||
return providers
|
||||
.connected()
|
||||
const available = createMemo(() => {
|
||||
const connected = providers.connected()
|
||||
const managedConsole = consoleProviderGroup(connected)
|
||||
const consoleConnected = integrations
|
||||
.list()
|
||||
.find((item) => item.id === "opencode")
|
||||
?.connections.some((connection) => connection.type === "credential" || connection.type === "env")
|
||||
const consoleTransition =
|
||||
state.connecting && providerConnect.selected() === "opencode" && consoleConnected && managedConsole === undefined
|
||||
return connected
|
||||
.filter(
|
||||
(provider) =>
|
||||
provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost.input > 0),
|
||||
provider.id !== "opencode" ||
|
||||
(!consoleTransition &&
|
||||
(managedConsole !== undefined ||
|
||||
consoleConnected ||
|
||||
Object.values(provider.models).some((model) => model.cost.input > 0))),
|
||||
)
|
||||
.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go"))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const ids = new Set(available().map((item) => item.id))
|
||||
Object.entries(state.disconnecting).forEach(([id, status]) => {
|
||||
if ((status === "removing" || status === "removed") && !ids.has(id)) {
|
||||
setState("disconnecting", id, "absent")
|
||||
return
|
||||
}
|
||||
if (status === "absent" && ids.has(id)) setState("disconnecting", id, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
const connected = createMemo(() => available().filter((item) => !state.disconnecting[item.id]))
|
||||
|
||||
const consoleGroup = createMemo(() => consoleProviderGroup(available()))
|
||||
|
||||
const displayed = createMemo(() => {
|
||||
const group = consoleGroup()
|
||||
if (!group) return connected()
|
||||
const grouped = new Set(group.providers.filter((item) => item.id !== group.root.id).map((item) => item.id))
|
||||
return connected().filter((item) => !grouped.has(item.id))
|
||||
})
|
||||
|
||||
const popular = createMemo(() => {
|
||||
const connectedIDs = new Set(connected().map((p) => p.id))
|
||||
const items = providers
|
||||
@@ -98,6 +177,10 @@ export const SettingsProviders: Component<{
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (state.disconnecting[providerID]) return
|
||||
const group = consoleGroup()
|
||||
const ids = group?.root.id === providerID ? group.providers.map((provider) => provider.id) : [providerID]
|
||||
updateDisconnecting(ids, "removing")
|
||||
const location = props.directory ? { directory: props.directory } : undefined
|
||||
await serverSdk.api.integration
|
||||
.get({ integrationID: providerID, location })
|
||||
@@ -107,6 +190,7 @@ export const SettingsProviders: Component<{
|
||||
await Promise.all(
|
||||
credentials.map((credential) => serverSdk.api.credential.remove({ credentialID: credential.id })),
|
||||
)
|
||||
updateDisconnecting(ids, "removed")
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
@@ -115,6 +199,7 @@ export const SettingsProviders: Component<{
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
updateDisconnecting(ids, undefined)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
@@ -136,38 +221,127 @@ export const SettingsProviders: Component<{
|
||||
<h3 class="settings-section-title">{language.t("settings.providers.section.connected")}</h3>
|
||||
<SettingsList variant="catalog">
|
||||
<Show
|
||||
when={connected().length > 0}
|
||||
when={displayed().length > 0}
|
||||
fallback={<div class="settings-provider-empty">{language.t("settings.providers.connected.empty")}</div>}
|
||||
>
|
||||
<For each={connected()}>
|
||||
{(item) => (
|
||||
<div class="settings-provider-row group">
|
||||
<div class="settings-provider-lead">
|
||||
<ProviderIcon
|
||||
id={item.id}
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-provider-icon shrink-0"
|
||||
/>
|
||||
<div class="settings-provider-main">
|
||||
<span class="settings-provider-name truncate">{item.name}</span>
|
||||
<Badge>{type(item)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<For each={displayed()}>
|
||||
{(item) => {
|
||||
const console = () => (consoleGroup()?.root.id === item.id ? consoleGroup() : undefined)
|
||||
return (
|
||||
<Show
|
||||
when={canDisconnect(item)}
|
||||
when={console()}
|
||||
fallback={
|
||||
<span class="settings-provider-env-hint">
|
||||
{language.t("settings.providers.connected.environmentDescription")}
|
||||
</span>
|
||||
<div class="settings-provider-row group">
|
||||
<div class="settings-provider-lead">
|
||||
<Show
|
||||
when={item.id === "opencode"}
|
||||
fallback={
|
||||
<ProviderIcon
|
||||
id={item.id}
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-provider-icon shrink-0"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<OpenCodeLogo class="settings-provider-icon size-4 shrink-0" />
|
||||
</Show>
|
||||
<div class="settings-provider-main">
|
||||
<span class="settings-provider-name truncate">{item.name}</span>
|
||||
<Badge>{type(item)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Show
|
||||
when={canDisconnect(item)}
|
||||
fallback={
|
||||
<span class="settings-provider-env-hint">
|
||||
{language.t("settings.providers.connected.environmentDescription")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onClick={() => void disconnect(item.id, item.name)}
|
||||
>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
{(group) => (
|
||||
<div class="settings-provider-console group">
|
||||
<div class="settings-provider-console-header">
|
||||
<div class="settings-provider-lead">
|
||||
<OpenCodeLogo class="settings-provider-icon size-4 shrink-0" />
|
||||
<div class="settings-provider-console-summary">
|
||||
<div class="settings-provider-main">
|
||||
<span class="settings-provider-name truncate">
|
||||
{language.t("provider.connect.opencode.name")}
|
||||
</span>
|
||||
<Badge>{group().workspace}</Badge>
|
||||
</div>
|
||||
<Show when={group().providers.length > 1}>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-provider-console-toggle"
|
||||
aria-expanded={state.consoleExpanded}
|
||||
onClick={() => setState("consoleExpanded", (value) => !value)}
|
||||
>
|
||||
<span>
|
||||
{language.plural(
|
||||
"settings.providers.console.available",
|
||||
group().providers.length,
|
||||
{ count: group().providers.length },
|
||||
)}
|
||||
</span>
|
||||
<Icon
|
||||
name="chevron-right"
|
||||
size="small"
|
||||
classList={{
|
||||
"settings-provider-console-chevron": true,
|
||||
open: state.consoleExpanded,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onClick={() => void disconnect(item.id, language.t("provider.connect.opencode.name"))}
|
||||
>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
<Show when={state.consoleExpanded}>
|
||||
<div class="settings-provider-console-list">
|
||||
<div class="settings-provider-console-separator" aria-hidden="true" />
|
||||
<For each={group().providers}>
|
||||
{(provider) => (
|
||||
<button
|
||||
type="button"
|
||||
class="settings-provider-console-item"
|
||||
onClick={() => props.onSelectProvider?.(provider.id)}
|
||||
>
|
||||
<span>{provider.name.slice(group().prefix.length)}</span>
|
||||
<Icon
|
||||
name="chevron-right"
|
||||
size="small"
|
||||
class="settings-provider-console-item-chevron"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</SettingsList>
|
||||
@@ -180,12 +354,19 @@ export const SettingsProviders: Component<{
|
||||
{(item) => (
|
||||
<div class="settings-provider-row">
|
||||
<div class="settings-provider-lead">
|
||||
<ProviderIcon
|
||||
id={item.id}
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-provider-icon shrink-0"
|
||||
/>
|
||||
<Show
|
||||
when={item.id === "opencode"}
|
||||
fallback={
|
||||
<ProviderIcon
|
||||
id={item.id}
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-provider-icon shrink-0"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<OpenCodeLogo class="settings-provider-icon size-4 shrink-0" />
|
||||
</Show>
|
||||
<div class="settings-provider-copy">
|
||||
<div class="settings-provider-main">
|
||||
<span class="settings-provider-name">{item.name}</span>
|
||||
|
||||
@@ -677,6 +677,134 @@
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
[data-component="connected-providers-section"] .settings-provider-row {
|
||||
min-height: 48px;
|
||||
padding-block: 10px;
|
||||
}
|
||||
|
||||
.settings-provider-console {
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-provider-console:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.settings-provider-console-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 48px;
|
||||
padding-block: 10px;
|
||||
}
|
||||
|
||||
.settings-provider-console-summary {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-provider-console-toggle {
|
||||
display: flex;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding-inline: 4px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.settings-provider-console-toggle:hover {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-provider-console-chevron {
|
||||
color: var(--v2-icon-icon-muted);
|
||||
transition: transform 150ms ease-out;
|
||||
}
|
||||
|
||||
.settings-provider-console-chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.settings-provider-console-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.settings-provider-console-separator {
|
||||
height: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
background: var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-provider-console-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding-inline: 10px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-provider-console-item-chevron {
|
||||
color: var(--v2-icon-icon-base);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.settings-provider-console-item:hover {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
.settings-provider-console-item:hover .settings-provider-console-item-chevron {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-provider-console-item:focus-visible {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
.settings-provider-console-item:focus-visible .settings-provider-console-item-chevron {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.settings-provider-console-chevron {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container settings-panel (max-width: 520px) {
|
||||
.settings-provider-console-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.settings-provider-row {
|
||||
flex-wrap: nowrap;
|
||||
@@ -938,6 +1066,18 @@
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
|
||||
.settings-models-group-chevron svg {
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.settings-models-group-chevron svg.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
:dir(rtl) .settings-models-group-chevron svg.collapsed {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.settings-models-group-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -950,6 +1090,149 @@
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.provider-model-groups {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-models-console-groups {
|
||||
margin-inline-start: 14px;
|
||||
padding-inline-start: 16px;
|
||||
border-inline-start: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.provider-model-group {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||
}
|
||||
|
||||
.provider-model-group-header {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.provider-model-group-trigger {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--v2-text-text-base);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.provider-model-group-trigger:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.provider-model-group-trigger:not(:disabled):hover {
|
||||
background-color: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
}
|
||||
|
||||
.provider-model-group-trigger:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.provider-model-group-label {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.provider-model-group-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.provider-model-group-chevron {
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: -2px;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.provider-model-group-chevron.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
:dir(rtl) .provider-model-group-chevron.collapsed {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.provider-model-group-detail {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.provider-model-group-models {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.provider-model-group-models > [data-component="settings-list"] {
|
||||
padding-inline: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border-top: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.provider-model-group:has(.provider-model-group-trigger:focus-visible)
|
||||
.provider-model-group-models
|
||||
> [data-component="settings-list"] {
|
||||
border-top-color: transparent;
|
||||
}
|
||||
|
||||
.provider-model-group:has(.provider-model-group-trigger:focus-visible) {
|
||||
outline: 0.5px solid var(--v2-border-border-muted);
|
||||
outline-offset: -0.5px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.provider-model-group:has(.provider-model-group-trigger:hover) {
|
||||
outline: 0.5px solid var(--v2-border-border-muted);
|
||||
outline-offset: -0.5px;
|
||||
}
|
||||
|
||||
.provider-model-group:has(.provider-model-group-trigger:hover)
|
||||
.provider-model-group-models
|
||||
> [data-component="settings-list"] {
|
||||
border-top-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-model-groups--dialog {
|
||||
padding-block: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.settings-models-group-chevron svg,
|
||||
.provider-model-group-chevron {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-models [data-component="provider-icon"] {
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
@@ -187,7 +187,10 @@ function RootSettings() {
|
||||
const tabs = useTabs()
|
||||
const servers = useServerCollectionController()
|
||||
const inventory = useSettingsServers()
|
||||
const [state, setState] = createStore({ worktreeFilterReset: 0 })
|
||||
const [state, setState] = createStore({
|
||||
worktreeFilterReset: 0,
|
||||
modelProvider: undefined as string | undefined,
|
||||
})
|
||||
const list = servers.collection.items
|
||||
const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined))
|
||||
const single = createMemo(() => singleEntry()?.connection)
|
||||
@@ -309,10 +312,22 @@ function RootSettings() {
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="providers" class="settings-panel">
|
||||
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
|
||||
<SettingsProviders
|
||||
directory={undefined}
|
||||
onBack={() => surface.select("providers")}
|
||||
onSelectProvider={(providerID) => {
|
||||
setState("modelProvider", providerID)
|
||||
surface.select("models")
|
||||
}}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="models" class="settings-panel">
|
||||
<SettingsModels active={surface.view().tab === "models"} autofocus={!surface.search.state.selected} />
|
||||
<SettingsModels
|
||||
active={surface.view().tab === "models"}
|
||||
autofocus={!surface.search.state.selected}
|
||||
provider={state.modelProvider}
|
||||
onReveal={() => setState("modelProvider", undefined)}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="extensions" class="settings-panel">
|
||||
<SettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
|
||||
@@ -336,7 +351,10 @@ function ServerSettings(props: { entry: SettingsServer }) {
|
||||
const surface = useSettingsSurface()
|
||||
const activeDirectory = useSettingsDirectory(() => props.entry.connection)
|
||||
const prefetchWorkspaces = useWorkspacesPrefetch(() => props.entry.connection)
|
||||
const [state, setState] = createStore({ worktreeFilterReset: 0 })
|
||||
const [state, setState] = createStore({
|
||||
worktreeFilterReset: 0,
|
||||
modelProvider: undefined as string | undefined,
|
||||
})
|
||||
const groups = createMemo<SettingsNavGroup[]>(() => [
|
||||
{
|
||||
items: nestedServerTabs.map((item) => ({
|
||||
@@ -391,10 +409,21 @@ function ServerSettings(props: { entry: SettingsServer }) {
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="providers" class="settings-panel">
|
||||
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
|
||||
<SettingsProviders
|
||||
directory={undefined}
|
||||
onBack={() => surface.select("providers")}
|
||||
onSelectProvider={(providerID) => {
|
||||
setState("modelProvider", providerID)
|
||||
surface.select("models")
|
||||
}}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="models" class="settings-panel">
|
||||
<SettingsModels active={surface.view().tab === "models"} />
|
||||
<SettingsModels
|
||||
active={surface.view().tab === "models"}
|
||||
provider={state.modelProvider}
|
||||
onReveal={() => setState("modelProvider", undefined)}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="extensions" class="settings-panel">
|
||||
<SettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { shell } from "electron"
|
||||
import { resolveExternalURL } from "../files/external-url"
|
||||
import { FileRpcs } from "../../shared/ipc-rpc"
|
||||
import { DesktopFiles, openExternalURL, openLocalFileURL } from "../files"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
@@ -18,13 +20,22 @@ export const fileHandlers = FileRpcs.toLayer(
|
||||
)
|
||||
.pipe(Effect.orDie),
|
||||
FilesReadPickedFile: ({ token, path }, context) =>
|
||||
files
|
||||
.readPickedFile(sender(handoff, context).id, token, path)
|
||||
.pipe(Effect.map((buffer) => new Uint8Array(buffer)), Effect.orDie),
|
||||
files.readPickedFile(sender(handoff, context).id, token, path).pipe(
|
||||
Effect.map((buffer) => new Uint8Array(buffer)),
|
||||
Effect.orDie,
|
||||
),
|
||||
FilesReleasePickedFiles: ({ token }, context) =>
|
||||
Effect.sync(() => files.releasePickedFiles(sender(handoff, context).id, token)),
|
||||
FilesSaveFile: ({ options, content }) => files.saveFile(options, content).pipe(Effect.orDie),
|
||||
FilesOpenExternal: ({ url }) => openExternalURL(url),
|
||||
FilesOpenBrowser: ({ url }) => {
|
||||
const target = resolveExternalURL(url)
|
||||
if (!target || !/^https?:/.test(target)) return Effect.succeed(false)
|
||||
return Effect.tryPromise(() => shell.openExternal(target)).pipe(
|
||||
Effect.as(true),
|
||||
Effect.orElseSucceed(() => false),
|
||||
)
|
||||
},
|
||||
FilesOpenLocalFile: ({ url }) => openLocalFileURL(url),
|
||||
FilesOpenPath: ({ path, application }) =>
|
||||
files.openPath(path, application).pipe(
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { consoleReturnWindow } from "./deep-link"
|
||||
|
||||
describe("Console return deep links", () => {
|
||||
test("reads the originating Desktop window", () => {
|
||||
expect(consoleReturnWindow("opencode://console/authorized?window=window-a")).toBe("window-a")
|
||||
expect(consoleReturnWindow("opencode://console/authorized?window=window%20b")).toBe("window b")
|
||||
})
|
||||
|
||||
test("rejects unrelated and malformed links", () => {
|
||||
expect(consoleReturnWindow("opencode://console/other?window=window-a")).toBeUndefined()
|
||||
expect(consoleReturnWindow("opencode://other/authorized?window=window-a")).toBeUndefined()
|
||||
expect(consoleReturnWindow("https://console/authorized?window=window-a")).toBeUndefined()
|
||||
expect(consoleReturnWindow("not a url")).toBeUndefined()
|
||||
expect(consoleReturnWindow("opencode://console/authorized")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
export function consoleReturnWindow(value: string) {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== "opencode:" || url.hostname !== "console" || url.pathname !== "/authorized") return
|
||||
const id = url.searchParams.get("window")
|
||||
if (!id || id.length > 256 || /[\u0000-\u001f\u007f]/.test(id)) return
|
||||
return id
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,11 @@ import { emitIpcEvent } from "../ipc-events"
|
||||
import { DesktopLogging, scoped } from "../native/logging"
|
||||
import { DesktopStorage } from "../storage"
|
||||
import { safeWebContentsURL } from "../windows/state"
|
||||
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
|
||||
import { getLastFocusedWindow, getWindowByID, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
|
||||
import { acquireApplicationLock, configureApplication } from "./environment"
|
||||
import { initializeFirstLaunchOnboarding } from "./onboarding"
|
||||
import { Shutdown } from "./shutdown"
|
||||
import { consoleReturnWindow } from "./deep-link"
|
||||
|
||||
export interface Interface {
|
||||
readonly relaunch: () => void
|
||||
@@ -34,11 +35,23 @@ const runtime = Layer.effect(
|
||||
const pendingDeepLinks: string[] = []
|
||||
let shutdownReady = false
|
||||
const prepareToRestart = shutdown.run.pipe(Effect.ensuring(Effect.sync(() => (shutdownReady = true))))
|
||||
const focusWindow = (win: BrowserWindow | null) => {
|
||||
if (!win) return
|
||||
if (win.isMinimized()) win.restore()
|
||||
win.show()
|
||||
win.focus()
|
||||
}
|
||||
const emitDeepLinks = (urls: string[]) => {
|
||||
if (!urls.length) return
|
||||
pendingDeepLinks.push(...urls)
|
||||
const win = getLastFocusedWindow()
|
||||
const target = urls.flatMap((url) => {
|
||||
const id = consoleReturnWindow(url)
|
||||
const win = id ? getWindowByID(id) : null
|
||||
return win ? [win] : []
|
||||
})[0]
|
||||
const win = target ?? getLastFocusedWindow()
|
||||
if (win) emitIpcEvent(win.webContents, new DeepLinksOpened({ urls }))
|
||||
return win
|
||||
}
|
||||
const relaunch = () => {
|
||||
setAppQuitting()
|
||||
@@ -57,17 +70,14 @@ const runtime = Layer.effect(
|
||||
const urls = argv.filter((arg) => arg.startsWith("opencode://"))
|
||||
if (urls.length) {
|
||||
runFork(Effect.logInfo("deep link received via second-instance", { urls }))
|
||||
emitDeepLinks(urls)
|
||||
focusWindow(emitDeepLinks(urls) ?? null)
|
||||
}
|
||||
const win = getLastFocusedWindow()
|
||||
if (!win) return
|
||||
win.show()
|
||||
win.focus()
|
||||
if (!urls.length) focusWindow(getLastFocusedWindow())
|
||||
}
|
||||
const openUrl = (event: Event, url: string) => {
|
||||
event.preventDefault()
|
||||
runFork(Effect.logInfo("deep link received via open-url", { url }))
|
||||
emitDeepLinks([url])
|
||||
focusWindow(emitDeepLinks([url]) ?? null)
|
||||
}
|
||||
const beforeQuit = (event: Event) => {
|
||||
setAppQuitting()
|
||||
|
||||
@@ -70,6 +70,12 @@ export function getLastFocusedWindow() {
|
||||
return win
|
||||
}
|
||||
|
||||
export function getWindowByID(id: string) {
|
||||
const win = registry.get(id)
|
||||
if (!win || win.isDestroyed()) return null
|
||||
return win
|
||||
}
|
||||
|
||||
export function setWindowThemeReady(win: BrowserWindow) {
|
||||
themeReady.get(win)?.()
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ describe("window registry", () => {
|
||||
app.registry.register("a", { name: "a" })
|
||||
app.registry.register("b", { name: "b" })
|
||||
expect(app.state.stored).toEqual(["a", "b"])
|
||||
expect(app.registry.get("a")).toEqual({ name: "a" })
|
||||
expect(app.registry.get("missing")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("forgets a deliberately closed window while others remain open", () => {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Tracks open windows and the persisted window id list used to restore
|
||||
// windows (and their per-window persisted state) across app launches.
|
||||
export function createWindowRegistry<W>(persistence: {
|
||||
read: () => unknown
|
||||
write: (ids: string[]) => void
|
||||
}) {
|
||||
export function createWindowRegistry<W>(persistence: { read: () => unknown; write: (ids: string[]) => void }) {
|
||||
const windows = new Map<string, W>()
|
||||
let quitting = false
|
||||
let lastFocusedID: string | undefined
|
||||
@@ -31,6 +28,9 @@ export function createWindowRegistry<W>(persistence: {
|
||||
if (!lastFocusedID) return
|
||||
return windows.get(lastFocusedID)
|
||||
},
|
||||
get(id: string) {
|
||||
return windows.get(id)
|
||||
},
|
||||
closed(id: string) {
|
||||
windows.delete(id)
|
||||
if (lastFocusedID === id) lastFocusedID = windows.keys().next().value
|
||||
|
||||
@@ -63,6 +63,7 @@ export type ElectronAPI = {
|
||||
getPathForFile(file: File): string
|
||||
saveFile(opts: SaveFilePickerOptions, content: string): Promise<boolean>
|
||||
openExternal(url: string): void
|
||||
openBrowser(url: string): Promise<boolean>
|
||||
openLocalFile(url: string): void
|
||||
openPath(path: string, app?: string): Promise<string | undefined>
|
||||
revealPath(path: string): Promise<boolean>
|
||||
|
||||
@@ -122,6 +122,7 @@ export const api: ElectronAPI = {
|
||||
getPathForFile: (file) => window.electron.getPathForFile(file),
|
||||
saveFile: (opts, content) => invoke("FilesSaveFile", { options: opts, content }),
|
||||
openExternal: (url) => send("FilesOpenExternal", { url }),
|
||||
openBrowser: (url) => invoke("FilesOpenBrowser", { url }),
|
||||
openLocalFile: (url) => send("FilesOpenLocalFile", { url }),
|
||||
openPath: (path, app) => invoke("FilesOpenPath", { path, application: app }).then((value) => value ?? undefined),
|
||||
revealPath: (path) => invoke("FilesRevealPath", { path }),
|
||||
|
||||
@@ -21,6 +21,7 @@ function fileApi(events: string[]) {
|
||||
getPathForFile: () => "fallback",
|
||||
saveFile: async () => false,
|
||||
openExternal: () => {},
|
||||
openBrowser: async () => true,
|
||||
openLocalFile: () => {},
|
||||
resolveAppPath: async () => null,
|
||||
openPath: async () => undefined,
|
||||
@@ -33,6 +34,10 @@ function fileApi(events: string[]) {
|
||||
}
|
||||
|
||||
describe("desktop attachment files", () => {
|
||||
test("reports native browser launch failure to the renderer", async () => {
|
||||
const files = createDesktopFiles({ ...fileApi([]), openBrowser: async () => false }, "macos", [])
|
||||
expect(await files.openBrowser("https://opencode.ai/console")).toBe(false)
|
||||
})
|
||||
test("reads selected files sequentially and releases the token", async () => {
|
||||
const events: string[] = []
|
||||
const files = createDesktopFiles(fileApi(events), "windows")
|
||||
|
||||
@@ -11,6 +11,7 @@ type DesktopFileAPI = Pick<
|
||||
| "getPathForFile"
|
||||
| "saveFile"
|
||||
| "openExternal"
|
||||
| "openBrowser"
|
||||
| "openLocalFile"
|
||||
| "resolveAppPath"
|
||||
| "openPath"
|
||||
@@ -55,6 +56,7 @@ export function createDesktopFiles(api: DesktopFileAPI, os: DesktopOS) {
|
||||
saveFile: (options: { title?: string; defaultPath?: string }, content: string) =>
|
||||
api.saveFile({ title: options.title, defaultPath: options.defaultPath }, content),
|
||||
openExternal: (url: string) => api.openExternal(url),
|
||||
openBrowser: (url: string) => api.openBrowser(url),
|
||||
openLocalFile: (url: string) => api.openLocalFile(url),
|
||||
async openPath(path: string, app?: string) {
|
||||
if (os !== "windows") {
|
||||
|
||||
@@ -43,6 +43,10 @@ export const FilesSaveFile = Rpc.make("FilesSaveFile", {
|
||||
export const FilesOpenExternal = Rpc.make("FilesOpenExternal", {
|
||||
payload: { url: Schema.String },
|
||||
})
|
||||
export const FilesOpenBrowser = Rpc.make("FilesOpenBrowser", {
|
||||
payload: { url: Schema.String },
|
||||
success: Schema.Boolean,
|
||||
})
|
||||
export const FilesOpenLocalFile = Rpc.make("FilesOpenLocalFile", {
|
||||
payload: { url: Schema.String },
|
||||
})
|
||||
@@ -68,6 +72,7 @@ export const FileRpcs = RpcGroup.make(
|
||||
FilesReleasePickedFiles,
|
||||
FilesSaveFile,
|
||||
FilesOpenExternal,
|
||||
FilesOpenBrowser,
|
||||
FilesOpenLocalFile,
|
||||
FilesOpenPath,
|
||||
FilesRevealPath,
|
||||
|
||||
@@ -43,9 +43,9 @@ story("renders aliased and long custom model notices", async ({ mount, page }) =
|
||||
await expect(short.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible()
|
||||
await expect(short.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh")
|
||||
await expect(timeline.getByText("fast-nano", { exact: true })).toHaveCount(0)
|
||||
await expect(short.locator('[data-component="provider-icon"]')).toBeVisible()
|
||||
await expect(short.locator('[data-component="logo-mark"]')).toBeVisible()
|
||||
await expect(long).toBeVisible()
|
||||
await expect(long.locator('[data-component="provider-icon"]')).toBeVisible()
|
||||
await expect(long.locator('[data-component="logo-mark"]')).toBeVisible()
|
||||
await expect(long.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
|
||||
await expect(long.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
|
||||
await expect.poll(() => long.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { ProviderIcon } from "@opencode/ui/provider-icon"
|
||||
import { Mark } from "@opencode/ui/logo"
|
||||
import { Show } from "solid-js"
|
||||
|
||||
export function TimelineSeparator(props: { label: string; providerID?: string; variant?: string }) {
|
||||
export function TimelineSeparator(props: { label: string; logo?: boolean; variant?: string }) {
|
||||
return (
|
||||
<div class="flex h-8 w-full items-center gap-3 text-v2-text-text-faint">
|
||||
<span class="h-px min-w-0 flex-1 bg-v2-border-border-strong" />
|
||||
<span class="flex min-w-0 items-center gap-1 text-[13px] font-[440] leading-text-compact tracking-[-0.04px]">
|
||||
<Show when={props.providerID}>
|
||||
{(providerID) => <ProviderIcon id={providerID()} class="text-v2-icon-icon-faint" aria-hidden="true" />}
|
||||
<Show when={props.logo}>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center" aria-hidden="true">
|
||||
<Mark class="h-4 w-[13px]" />
|
||||
</span>
|
||||
</Show>
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<bdi dir="auto" class="truncate" title={props.label}>
|
||||
|
||||
@@ -392,7 +392,6 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
if (value?.type !== "model-switched") return undefined
|
||||
const match = data.store.provider?.all?.get(value.model.providerID)
|
||||
return {
|
||||
providerID: value.model.providerID,
|
||||
variant: value.model.variant,
|
||||
label: i18n.t("ui.sessionTimeline.notice.modelSwitched", {
|
||||
model: match?.models?.[value.model.id]?.name ?? value.model.id,
|
||||
@@ -508,7 +507,7 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
>
|
||||
{(model) => (
|
||||
<div data-slot="session-timeline-notice" data-type="model-switched" class={`w-full py-2 ${inset()}`}>
|
||||
<TimelineSeparator label={model().label} providerID={model().providerID} variant={model().variant} />
|
||||
<TimelineSeparator label={model().label} logo variant={model().variant} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Plugin } from "@opencode/plugin/tui"
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createSignal, Show } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { Spinner } from "../../component/spinner"
|
||||
import { useConfig } from "../../config"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
@@ -90,8 +90,6 @@ function Answer(props: { question: string; answer: string }) {
|
||||
const overlay = useTheme("overlay")
|
||||
const syntax = useThemes().currentSyntax
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2)))
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
@@ -111,8 +109,8 @@ function Answer(props: { question: string; answer: string }) {
|
||||
if (!scroll) return
|
||||
if (event.name === "up") return scroll.scrollBy(-1)
|
||||
if (event.name === "down") return scroll.scrollBy(1)
|
||||
if (event.name === "pageup") return scroll.scrollBy(-maxHeight())
|
||||
if (event.name === "pagedown") return scroll.scrollBy(maxHeight())
|
||||
if (event.name === "pageup") return scroll.scrollBy(-20)
|
||||
if (event.name === "pagedown") return scroll.scrollBy(20)
|
||||
if (event.name === "home") return scroll.scrollTo(0)
|
||||
if (event.name === "end") return scroll.scrollTo(scroll.scrollHeight)
|
||||
})
|
||||
@@ -128,13 +126,15 @@ function Answer(props: { question: string; answer: string }) {
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
{props.question}
|
||||
</text>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
{props.question}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
maxHeight={maxHeight()}
|
||||
maxHeight={20}
|
||||
backgroundColor={overlay.background.default}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={getScrollAcceleration(config)}
|
||||
|
||||
@@ -17,6 +17,12 @@ import { Dialog as Kobalte } from "@kobalte/core/dialog"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
|
||||
type DialogElement = () => JSX.Element
|
||||
export type DialogOptions = { dismissOnBackdrop?: boolean }
|
||||
const DialogOptionsContext = createContext<DialogOptions>({})
|
||||
|
||||
export function useDialogOptions() {
|
||||
return useContext(DialogOptionsContext)
|
||||
}
|
||||
|
||||
type Active = {
|
||||
id: string
|
||||
@@ -75,7 +81,13 @@ function init() {
|
||||
makeEventListener(window, "keydown", onKeyDown, { capture: true })
|
||||
})
|
||||
|
||||
const mount = (element: DialogElement, owner: Owner, onClose: (() => void) | undefined, layer: number) => {
|
||||
const mount = (
|
||||
element: DialogElement,
|
||||
owner: Owner,
|
||||
onClose: (() => void) | undefined,
|
||||
layer: number,
|
||||
options: DialogOptions = {},
|
||||
) => {
|
||||
const id = Math.random().toString(36).slice(2)
|
||||
const zIndex = 50 + layer * 10
|
||||
let dispose: (() => void) | undefined
|
||||
@@ -100,7 +112,10 @@ function init() {
|
||||
<Kobalte.Overlay
|
||||
data-component="dialog-overlay"
|
||||
style={{ "z-index": String(zIndex) }}
|
||||
onClick={() => close(id)}
|
||||
onClick={() => {
|
||||
if (options.dismissOnBackdrop === false) return
|
||||
close(id)
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
data-dialog-layer={layer}
|
||||
@@ -114,7 +129,7 @@ function init() {
|
||||
"pointer-events": "none",
|
||||
}}
|
||||
>
|
||||
{element()}
|
||||
<DialogOptionsContext.Provider value={options}>{element()}</DialogOptionsContext.Provider>
|
||||
</div>
|
||||
</Kobalte.Portal>
|
||||
</Kobalte>
|
||||
@@ -128,16 +143,16 @@ function init() {
|
||||
setStack((items) => [...items, active])
|
||||
}
|
||||
|
||||
const push = (element: DialogElement, owner: Owner, onClose?: () => void) => {
|
||||
const push = (element: DialogElement, owner: Owner, onClose?: () => void, options?: DialogOptions) => {
|
||||
if (timer.current !== undefined) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = undefined
|
||||
}
|
||||
lock.value = false
|
||||
mount(element, owner, onClose, stack().length)
|
||||
mount(element, owner, onClose, stack().length, options)
|
||||
}
|
||||
|
||||
const show = (element: DialogElement, owner: Owner, onClose?: () => void) => {
|
||||
const show = (element: DialogElement, owner: Owner, onClose?: () => void, options?: DialogOptions) => {
|
||||
for (const item of stack()) item.dispose()
|
||||
setStack([])
|
||||
if (timer.current !== undefined) {
|
||||
@@ -145,7 +160,7 @@ function init() {
|
||||
timer.current = undefined
|
||||
}
|
||||
lock.value = false
|
||||
mount(element, owner, onClose, 0)
|
||||
mount(element, owner, onClose, 0, options)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -183,13 +198,13 @@ export function useDialog() {
|
||||
get active() {
|
||||
return ctx.stack().at(-1)
|
||||
},
|
||||
show(element: DialogElement, onClose?: () => void) {
|
||||
show(element: DialogElement, onClose?: () => void, options?: DialogOptions) {
|
||||
const base = ctx.stack().at(-1)?.owner ?? owner
|
||||
return startTransition(() => ctx.show(element, base, onClose))
|
||||
return startTransition(() => ctx.show(element, base, onClose, options))
|
||||
},
|
||||
push(element: DialogElement, onClose?: () => void) {
|
||||
push(element: DialogElement, onClose?: () => void, options?: DialogOptions) {
|
||||
const base = ctx.stack().at(-1)?.owner ?? owner
|
||||
return startTransition(() => ctx.push(element, base, onClose))
|
||||
return startTransition(() => ctx.push(element, base, onClose, options))
|
||||
},
|
||||
close() {
|
||||
ctx.close()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CloseButton, Content, Description, Title } from "@kobalte/core/dialog"
|
||||
import { type ComponentProps, type JSXElement, type ParentProps, Show, children, splitProps } from "solid-js"
|
||||
import { useI18n } from "../../context/i18n"
|
||||
import { useDialogOptions } from "../../context/dialog"
|
||||
import "./dialog.css"
|
||||
|
||||
export interface DialogProps extends ParentProps {
|
||||
@@ -10,6 +11,7 @@ export interface DialogProps extends ParentProps {
|
||||
containerClass?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
fit?: boolean
|
||||
onCloseAutoFocus?: ComponentProps<typeof Content>["onCloseAutoFocus"]
|
||||
}
|
||||
|
||||
export interface DialogHeaderProps extends ParentProps {
|
||||
@@ -19,7 +21,7 @@ export interface DialogHeaderProps extends ParentProps {
|
||||
|
||||
export interface DialogTitleGroupProps {
|
||||
title?: JSXElement
|
||||
description: JSXElement
|
||||
description?: JSXElement
|
||||
}
|
||||
|
||||
export function DialogFooter(props: ParentProps) {
|
||||
@@ -46,7 +48,7 @@ export function DialogTitleGroup(props: DialogTitleGroupProps) {
|
||||
return (
|
||||
<div data-slot="dialog-title-group">
|
||||
<Show when={title()}>{(t) => <Title data-slot="dialog-title">{t()}</Title>}</Show>
|
||||
<Description data-slot="dialog-description">{description()}</Description>
|
||||
<Show when={description()}>{(value) => <Description data-slot="dialog-description">{value()}</Description>}</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -82,7 +84,17 @@ export function DialogHeader(props: DialogHeaderProps) {
|
||||
}
|
||||
|
||||
export function Dialog(props: DialogProps) {
|
||||
const [local] = splitProps(props, ["size", "variant", "class", "containerClass", "classList", "fit", "children"])
|
||||
const options = useDialogOptions()
|
||||
const [local] = splitProps(props, [
|
||||
"size",
|
||||
"variant",
|
||||
"class",
|
||||
"containerClass",
|
||||
"classList",
|
||||
"fit",
|
||||
"children",
|
||||
"onCloseAutoFocus",
|
||||
])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -94,6 +106,10 @@ export function Dialog(props: DialogProps) {
|
||||
<div data-slot="dialog-container" class={local.containerClass}>
|
||||
<Content
|
||||
data-slot="dialog-content"
|
||||
onCloseAutoFocus={local.onCloseAutoFocus}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (options.dismissOnBackdrop === false) event.preventDefault()
|
||||
}}
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
|
||||
Reference in New Issue
Block a user