From c0d39f7aef85226f6c3a1e7b0253723c02f07091 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Tue, 24 Mar 2026 20:20:59 +0100 Subject: [PATCH 01/27] Add provider health and per-provider controls --- apps/server/src/codexAppServerManager.test.ts | 4 +- apps/server/src/codexAppServerManager.ts | 59 +--- .../provider/Layers/ProviderHealth.test.ts | 3 +- .../src/provider/Layers/ProviderHealth.ts | 180 ++++++++++-- .../src/provider/Services/ProviderHealth.ts | 30 +- apps/server/src/provider/codexAccount.ts | 63 +++++ apps/server/src/wsServer.test.ts | 173 ++++++------ apps/server/src/wsServer.ts | 34 ++- apps/web/src/appSettings.test.ts | 53 ++++ apps/web/src/appSettings.ts | 36 +++ apps/web/src/components/ChatView.tsx | 127 ++++++++- apps/web/src/components/GitActionsControl.tsx | 75 +---- .../chat/ProviderModelPicker.browser.tsx | 6 + .../components/chat/ProviderModelPicker.tsx | 105 ++++++- apps/web/src/routes/_chat.settings.tsx | 266 +++++++++++++++++- apps/web/src/wsNativeApi.test.ts | 81 ++---- apps/web/src/wsNativeApi.ts | 26 +- packages/contracts/src/ipc.ts | 21 +- packages/contracts/src/server.ts | 28 ++ packages/contracts/src/ws.test.ts | 30 +- packages/contracts/src/ws.ts | 23 +- 21 files changed, 1054 insertions(+), 369 deletions(-) create mode 100644 apps/server/src/provider/codexAccount.ts diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 80323c7441..f0a1d8259f 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -13,9 +13,9 @@ import { classifyCodexStderrLine, isRecoverableThreadResumeError, normalizeCodexModelSlug, - readCodexAccountSnapshot, resolveCodexModelForAccount, } from "./codexAppServerManager"; +import { readCodexAccountSnapshot } from "./provider/codexAccount"; const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); @@ -255,6 +255,7 @@ describe("readCodexAccountSnapshot", () => { }), ).toEqual({ type: "chatgpt", + email: "plus@example.com", planType: "plus", sparkEnabled: false, }); @@ -269,6 +270,7 @@ describe("readCodexAccountSnapshot", () => { }), ).toEqual({ type: "chatgpt", + email: "pro@example.com", planType: "pro", sparkEnabled: true, }); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 0ac37db3e8..39d281f74c 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -27,6 +27,7 @@ import { isCodexCliVersionSupported, parseCodexCliVersion, } from "./provider/codexCliVersion"; +import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./provider/codexAccount"; type PendingRequestKey = string; @@ -97,23 +98,6 @@ interface JsonRpcNotification { params?: unknown; } -type CodexPlanType = - | "free" - | "go" - | "plus" - | "pro" - | "team" - | "business" - | "enterprise" - | "edu" - | "unknown"; - -interface CodexAccountSnapshot { - readonly type: "apiKey" | "chatgpt" | "unknown"; - readonly planType: CodexPlanType | null; - readonly sparkEnabled: boolean; -} - export interface CodexAppServerSendTurnInput { readonly threadId: ThreadId; readonly input?: string; @@ -164,47 +148,6 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ ]; const CODEX_DEFAULT_MODEL = "gpt-5.3-codex"; const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark"; -const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set(["free", "go", "plus"]); - -function asObject(value: unknown): Record | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - return value as Record; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapshot { - const record = asObject(response); - const account = asObject(record?.account) ?? record; - const accountType = asString(account?.type); - - if (accountType === "apiKey") { - return { - type: "apiKey", - planType: null, - sparkEnabled: true, - }; - } - - if (accountType === "chatgpt") { - const planType = (account?.planType as CodexPlanType | null) ?? "unknown"; - return { - type: "chatgpt", - planType, - sparkEnabled: !CODEX_SPARK_DISABLED_PLAN_TYPES.has(planType), - }; - } - - return { - type: "unknown", - planType: null, - sparkEnabled: true, - }; -} export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index e24f07bcfa..bcd23f7950 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -609,7 +609,8 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { it("JSON with loggedIn=true is authenticated", () => { const parsed = parseClaudeAuthStatusFromOutput({ - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stdout: + '{"loggedIn":true,"authMethod":"claude.ai","email":"claude@example.com","subscriptionType":"pro"}\n', stderr: "", code: 0, }); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index cbb97a807e..f570d6bdf8 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -14,20 +14,28 @@ import type { ServerProviderStatus, ServerProviderStatusState, } from "@t3tools/contracts"; -import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; +import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Ref, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type { ProviderKind } from "@t3tools/contracts"; + import { formatCodexCliUpgradeMessage, isCodexCliVersionSupported, parseCodexCliVersion, } from "../codexCliVersion"; -import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; +import { + ProviderHealth, + type ProviderAuthActionResult, + type ProviderHealthShape, +} from "../Services/ProviderHealth"; const DEFAULT_TIMEOUT_MS = 4_000; const CODEX_PROVIDER = "codex" as const; const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const; +const CLI_VERSION_PATTERN = /\bv?(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?)\b/; + // ── Pure helpers ──────────────────────────────────────────────────── export interface CommandResult { @@ -84,6 +92,16 @@ function extractAuthBoolean(value: unknown): boolean | undefined { return undefined; } +function parseJsonOutput(result: CommandResult): unknown { + const trimmed = result.stdout.trim(); + if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) return undefined; + try { + return JSON.parse(trimmed); + } catch { + return undefined; + } +} + export function parseAuthStatusFromOutput(result: CommandResult): { readonly status: ServerProviderStatusState; readonly authStatus: ServerProviderAuthStatus; @@ -345,6 +363,7 @@ export const checkCodexProviderStatus: Effect.Effect< authStatus: "unknown" as const, checkedAt, message: formatCodexCliUpgradeMessage(parsedVersion), + ...(parsedVersion ? { version: parsedVersion } : {}), }; } @@ -362,6 +381,7 @@ export const checkCodexProviderStatus: Effect.Effect< authStatus: "unknown" as const, checkedAt, message: "Using a custom Codex model provider; OpenAI login check skipped.", + ...(parsedVersion ? { version: parsedVersion } : {}), } satisfies ServerProviderStatus; } @@ -404,6 +424,7 @@ export const checkCodexProviderStatus: Effect.Effect< authStatus: parsed.authStatus, checkedAt, ...(parsed.message ? { message: parsed.message } : {}), + ...(parsedVersion ? { version: parsedVersion } : {}), } satisfies ServerProviderStatus; }); @@ -444,20 +465,11 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): { } // `claude auth status` returns JSON with a `loggedIn` boolean. - const parsedAuth = (() => { - const trimmed = result.stdout.trim(); - if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - try { - return { - attemptedJsonParse: true as const, - auth: extractAuthBoolean(JSON.parse(trimmed)), - }; - } catch { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - })(); + const parsedJson = parseJsonOutput(result); + const parsedAuth = { + attemptedJsonParse: parsedJson !== undefined, + auth: extractAuthBoolean(parsedJson), + }; if (parsedAuth.auth === true) { return { status: "ready", authStatus: "authenticated" }; @@ -544,6 +556,9 @@ export const checkClaudeProviderStatus: Effect.Effect< }; } + const claudeVersionMatch = CLI_VERSION_PATTERN.exec(`${version.stdout}\n${version.stderr}`); + const claudeVersion = claudeVersionMatch?.[1] ?? undefined; + // Probe 2: `claude auth status` — is the user authenticated? const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), @@ -562,6 +577,7 @@ export const checkClaudeProviderStatus: Effect.Effect< error instanceof Error ? `Could not verify Claude authentication status: ${error.message}.` : "Could not verify Claude authentication status.", + ...(claudeVersion ? { version: claudeVersion } : {}), }; } @@ -573,6 +589,7 @@ export const checkClaudeProviderStatus: Effect.Effect< authStatus: "unknown" as const, checkedAt, message: "Could not verify Claude authentication status. Timed out while running command.", + ...(claudeVersion ? { version: claudeVersion } : {}), }; } @@ -584,20 +601,145 @@ export const checkClaudeProviderStatus: Effect.Effect< authStatus: parsed.authStatus, checkedAt, ...(parsed.message ? { message: parsed.message } : {}), + ...(claudeVersion ? { version: claudeVersion } : {}), } satisfies ServerProviderStatus; }); +// ── Auth action helpers ────────────────────────────────────────────── + +const LOGIN_TIMEOUT_MS = 30_000; +const LOGOUT_TIMEOUT_MS = 10_000; + +function loginArgs(provider: ProviderKind): { + run: (args: ReadonlyArray) => ReturnType; + args: ReadonlyArray; +} { + switch (provider) { + case "codex": + return { run: runCodexCommand, args: ["login"] }; + case "claudeAgent": + return { run: runClaudeCommand, args: ["auth", "login"] }; + } +} + +function logoutArgs(provider: ProviderKind): { + run: (args: ReadonlyArray) => ReturnType; + args: ReadonlyArray; +} { + switch (provider) { + case "codex": + return { run: runCodexCommand, args: ["logout"] }; + case "claudeAgent": + return { run: runClaudeCommand, args: ["auth", "logout"] }; + } +} + +function providerCheck( + provider: ProviderKind, +): Effect.Effect< + ServerProviderStatus, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + switch (provider) { + case CODEX_PROVIDER: + return checkCodexProviderStatus; + case CLAUDE_AGENT_PROVIDER: + return checkClaudeProviderStatus; + } +} + // ── Layer ─────────────────────────────────────────────────────────── export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { - const statusesFiber = yield* Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runProviderChecks = Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], { concurrency: "unbounded", - }).pipe(Effect.forkScoped); + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const statusesFiber = yield* runProviderChecks.pipe(Effect.forkScoped); + const initialStatuses: ReadonlyArray = yield* Fiber.join(statusesFiber); + const statusesRef = yield* Ref.make>(initialStatuses); + + const refreshAndStore = Effect.flatMap(runProviderChecks, (statuses) => + Ref.set(statusesRef, statuses).pipe(Effect.as(statuses)), + ); + const refreshStatusAndStore = (provider: ProviderKind) => + Effect.gen(function* () { + const nextStatus = yield* providerCheck(provider).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const currentStatuses = yield* Ref.get(statusesRef); + const providers = currentStatuses.map((status) => + status.provider === provider ? nextStatus : status, + ); + yield* Ref.set(statusesRef, providers); + return providers; + }); + + const runAuthAction = ( + provider: ProviderKind, + getConfig: (p: ProviderKind) => ReturnType, + timeoutMs: number, + ): Effect.Effect => + Effect.gen(function* () { + const { run, args } = getConfig(provider); + const result = yield* run(args).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.timeoutOption(timeoutMs), + Effect.result, + ); + + if (Result.isFailure(result)) { + const error = result.failure; + const providers = yield* refreshAndStore; + return { + success: false, + message: error instanceof Error ? error.message : "Command failed.", + providers, + }; + } + + if (Option.isNone(result.success)) { + const providers = yield* refreshAndStore; + return { + success: false, + message: "Command timed out.", + providers, + }; + } + + const cmd = result.success.value; + const providers = yield* refreshAndStore; + return { + success: cmd.code === 0, + ...(cmd.code !== 0 + ? { + message: + nonEmptyTrimmed(cmd.stderr) ?? + nonEmptyTrimmed(cmd.stdout) ?? + `Command exited with code ${cmd.code}.`, + } + : {}), + providers, + }; + }); return { - getStatuses: Fiber.join(statusesFiber), + getStatuses: Ref.get(statusesRef), + refreshStatuses: refreshAndStore, + refreshStatus: refreshStatusAndStore, + login: (provider: ProviderKind) => runAuthAction(provider, loginArgs, LOGIN_TIMEOUT_MS), + logout: (provider: ProviderKind) => runAuthAction(provider, logoutArgs, LOGOUT_TIMEOUT_MS), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Services/ProviderHealth.ts b/apps/server/src/provider/Services/ProviderHealth.ts index ec3b2d318d..faab76a18c 100644 --- a/apps/server/src/provider/Services/ProviderHealth.ts +++ b/apps/server/src/provider/Services/ProviderHealth.ts @@ -6,15 +6,43 @@ * * @module ProviderHealth */ -import type { ServerProviderStatus } from "@t3tools/contracts"; +import type { ProviderKind, ServerProviderStatus } from "@t3tools/contracts"; import { ServiceMap } from "effect"; import type { Effect } from "effect"; +export interface ProviderAuthActionResult { + readonly success: boolean; + readonly message?: string; + readonly providers: ReadonlyArray; +} + export interface ProviderHealthShape { /** * Read the latest provider health statuses. */ readonly getStatuses: Effect.Effect>; + + /** + * Re-run provider health probes and update the cached snapshot. + */ + readonly refreshStatuses: Effect.Effect>; + + /** + * Re-run provider health probes for a single provider and update the cached snapshot. + */ + readonly refreshStatus: ( + provider: ProviderKind, + ) => Effect.Effect>; + + /** + * Run the login command for a provider and refresh statuses. + */ + readonly login: (provider: ProviderKind) => Effect.Effect; + + /** + * Run the logout command for a provider and refresh statuses. + */ + readonly logout: (provider: ProviderKind) => Effect.Effect; } export class ProviderHealth extends ServiceMap.Service()( diff --git a/apps/server/src/provider/codexAccount.ts b/apps/server/src/provider/codexAccount.ts new file mode 100644 index 0000000000..c8dc4a6d06 --- /dev/null +++ b/apps/server/src/provider/codexAccount.ts @@ -0,0 +1,63 @@ +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export type CodexPlanType = + | "free" + | "go" + | "plus" + | "pro" + | "team" + | "business" + | "enterprise" + | "edu" + | "unknown"; + +export interface CodexAccountSnapshot { + readonly type: "apiKey" | "chatgpt" | "unknown"; + readonly email?: string; + readonly planType: CodexPlanType | null; + readonly sparkEnabled: boolean; +} + +const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set(["free", "go", "plus"]); + +export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapshot { + const record = asObject(response); + const account = asObject(record?.account) ?? record; + const accountType = asString(account?.type); + const email = asString(account?.email)?.trim() || undefined; + + if (accountType === "apiKey") { + return { + type: "apiKey", + ...(email ? { email } : {}), + planType: null, + sparkEnabled: true, + }; + } + + if (accountType === "chatgpt") { + const planType = (account?.planType as CodexPlanType | null) ?? "unknown"; + return { + type: "chatgpt", + ...(email ? { email } : {}), + planType, + sparkEnabled: !CODEX_SPARK_DISABLED_PLAN_TYPES.has(planType), + }; + } + + return { + type: "unknown", + ...(email ? { email } : {}), + planType: null, + sparkEnabled: true, + }; +} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 7dc4a59e7c..7b5c4d357d 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -76,6 +76,10 @@ const defaultProviderStatuses: ReadonlyArray = [ const defaultProviderHealthService: ProviderHealthShape = { getStatuses: Effect.succeed(defaultProviderStatuses), + refreshStatuses: Effect.succeed(defaultProviderStatuses), + refreshStatus: () => Effect.succeed(defaultProviderStatuses), + login: () => Effect.succeed({ success: true, providers: defaultProviderStatuses }), + logout: () => Effect.succeed({ success: true, providers: defaultProviderStatuses }), }; class MockTerminalManager implements TerminalManagerShape { @@ -1123,6 +1127,88 @@ describe("WebSocket Server", () => { ); }); + it("refreshes provider statuses on demand", async () => { + const refreshedProviders: ReadonlyArray = [ + { + provider: "codex", + status: "warning", + available: true, + authStatus: "unknown", + checkedAt: "2026-01-02T00:00:00.000Z", + message: "Could not verify Codex authentication status.", + }, + ]; + + server = await createTestServer({ + cwd: "/my/workspace", + providerHealth: { + getStatuses: Effect.succeed(defaultProviderStatuses), + refreshStatuses: Effect.succeed(refreshedProviders), + refreshStatus: () => Effect.succeed(refreshedProviders), + login: () => Effect.succeed({ success: true, providers: refreshedProviders }), + logout: () => Effect.succeed({ success: true, providers: refreshedProviders }), + }, + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.serverRefreshProviderStatuses); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + providers: refreshedProviders, + }); + }); + + it("refreshes a single provider status on demand", async () => { + const refreshedProviders: ReadonlyArray = [ + { + provider: "codex", + status: "warning", + available: true, + authStatus: "unknown", + checkedAt: "2026-01-02T00:00:00.000Z", + message: "Could not verify Codex authentication status.", + }, + { + provider: "claudeAgent", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: "2026-01-01T00:00:00.000Z", + }, + ]; + + server = await createTestServer({ + cwd: "/my/workspace", + providerHealth: { + getStatuses: Effect.succeed(defaultProviderStatuses), + refreshStatuses: Effect.succeed(defaultProviderStatuses), + refreshStatus: (provider) => { + expect(provider).toBe("codex"); + return Effect.succeed(refreshedProviders); + }, + login: () => Effect.succeed({ success: true, providers: refreshedProviders }), + logout: () => Effect.succeed({ success: true, providers: refreshedProviders }), + }, + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.serverRefreshProviderStatus, { + provider: "codex", + }); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + providers: refreshedProviders, + }); + }); + it("returns error for unknown methods", async () => { server = await createTestServer({ cwd: "/test" }); const addr = server.address(); @@ -1835,7 +1921,6 @@ describe("WebSocket Server", () => { connections.push(ws); const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, { - actionId: "client-action-1", cwd: "/test", action: "commit_push", modelSelection: { @@ -1845,96 +1930,14 @@ describe("WebSocket Server", () => { }); expect(response.result).toBeUndefined(); expect(response.error?.message).toContain("detached HEAD"); - expect(runStackedAction).toHaveBeenCalledWith( - { - actionId: "client-action-1", - cwd: "/test", - action: "commit_push", - modelSelection: { - provider: "codex", - model: "gpt-5.4-mini", - }, - }, - expect.objectContaining({ - actionId: "client-action-1", - progressReporter: expect.any(Object), - }), - ); - }); - - it("publishes git action progress only to the initiating websocket", async () => { - const runStackedAction = vi.fn( - (_input, options) => - options?.progressReporter - ?.publish({ - actionId: options.actionId ?? "action-1", - cwd: "/test", - action: "commit", - kind: "phase_started", - phase: "commit", - label: "Committing...", - }) - .pipe( - Effect.flatMap(() => - Effect.succeed({ - action: "commit" as const, - branch: { status: "skipped_not_requested" as const }, - commit: { - status: "created" as const, - commitSha: "abc1234", - subject: "Test commit", - }, - push: { status: "skipped_not_requested" as const }, - pr: { status: "skipped_not_requested" as const }, - }), - ), - ) ?? Effect.void, - ); - const gitManager: GitManagerShape = { - status: vi.fn(() => Effect.void as any), - resolvePullRequest: vi.fn(() => Effect.void as any), - preparePullRequestThread: vi.fn(() => Effect.void as any), - runStackedAction, - }; - - server = await createTestServer({ cwd: "/test", gitManager }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const [initiatingWs] = await connectAndAwaitWelcome(port); - const [otherWs] = await connectAndAwaitWelcome(port); - connections.push(initiatingWs, otherWs); - - const responsePromise = sendRequest(initiatingWs, WS_METHODS.gitRunStackedAction, { - actionId: "client-action-2", + expect(runStackedAction).toHaveBeenCalledWith({ cwd: "/test", - action: "commit", + action: "commit_push", modelSelection: { provider: "codex", model: "gpt-5.4-mini", }, }); - const progressPush = await waitForPush(initiatingWs, WS_CHANNELS.gitActionProgress); - - expect(progressPush.data).toEqual({ - actionId: "client-action-2", - cwd: "/test", - action: "commit", - kind: "phase_started", - phase: "commit", - label: "Committing...", - }); - - await expect( - waitForPush(otherWs, WS_CHANNELS.gitActionProgress, undefined, 10, 100), - ).rejects.toThrow("Timed out waiting for WebSocket message after 100ms"); - await expect(responsePromise).resolves.toEqual( - expect.objectContaining({ - result: expect.objectContaining({ - action: "commit", - }), - }), - ); }); it("rejects websocket connections without a valid auth token", async () => { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index bcb3850e7a..d0f9f0f748 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -269,7 +269,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ); const providerStatuses = yield* providerHealth.getStatuses; - const clients = yield* Ref.make(new Set()); const logger = createLogger("ws"); const readiness = yield* makeServerReadiness; @@ -710,7 +709,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]), ); - const routeRequest = Effect.fnUntraced(function* (ws: WebSocket, request: WebSocketRequest) { + const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) { switch (request.body._tag) { case ORCHESTRATION_WS_METHODS.getSnapshot: return yield* projectionReadModelQuery.getSnapshot(); @@ -799,13 +798,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.gitRunStackedAction: { const body = stripRequestTag(request.body); - return yield* gitManager.runStackedAction(body, { - actionId: body.actionId, - progressReporter: { - publish: (event) => - pushBus.publishClient(ws, WS_CHANNELS.gitActionProgress, event).pipe(Effect.asVoid), - }, - }); + return yield* gitManager.runStackedAction(body); } case WS_METHODS.gitResolvePullRequest: { @@ -889,6 +882,27 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< availableEditors, }; + case WS_METHODS.serverRefreshProviderStatuses: { + const providers = yield* providerHealth.refreshStatuses; + return { providers }; + } + + case WS_METHODS.serverRefreshProviderStatus: { + const body = stripRequestTag(request.body); + const providers = yield* providerHealth.refreshStatus(body.provider); + return { providers }; + } + + case WS_METHODS.serverProviderLogin: { + const body = stripRequestTag(request.body); + return yield* providerHealth.login(body.provider); + } + + case WS_METHODS.serverProviderLogout: { + const body = stripRequestTag(request.body); + return yield* providerHealth.logout(body.provider); + } + case WS_METHODS.serverUpsertKeybinding: { const body = stripRequestTag(request.body); const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body); @@ -927,7 +941,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); } - const result = yield* Effect.exit(routeRequest(ws, request.success)); + const result = yield* Effect.exit(routeRequest(request.success)); if (Exit.isFailure(result)) { return yield* sendWsResponse({ id: request.success.id, diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index fea74edd72..00127117c3 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -6,7 +6,10 @@ import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, DEFAULT_SIDEBAR_THREAD_SORT_ORDER, DEFAULT_TIMESTAMP_FORMAT, + getEnabledProviderOptions, getProviderStartOptions, + isProviderEnabled, + patchProviderEnabled, } from "./appSettings"; import { getAppModelOptions, @@ -242,6 +245,56 @@ describe("provider-indexed custom model settings", () => { }); }); +describe("provider enablement", () => { + it("defaults providers to enabled when decoding older persisted settings", () => { + const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema)); + + expect( + decode( + JSON.stringify({ + codexBinaryPath: "/usr/local/bin/codex", + }), + ).enabledProviders, + ).toEqual({ + codex: true, + claudeAgent: true, + }); + }); + + it("reads enabled providers", () => { + const settings = { + enabledProviders: { + codex: true, + claudeAgent: false, + }, + } as const; + + expect(isProviderEnabled(settings, "codex")).toBe(true); + expect(isProviderEnabled(settings, "claudeAgent")).toBe(false); + expect(getEnabledProviderOptions(settings)).toEqual(["codex"]); + }); + + it("patches provider enabled state without resetting other providers", () => { + expect( + patchProviderEnabled( + { + enabledProviders: { + codex: true, + claudeAgent: true, + }, + }, + "claudeAgent", + false, + ), + ).toEqual({ + enabledProviders: { + codex: true, + claudeAgent: false, + }, + }); + }); +}); + describe("AppSettingsSchema", () => { it("fills decoding defaults for persisted settings that predate newer keys", () => { const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema)); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index e2aac52a84..5742ea3934 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -3,6 +3,7 @@ import { Option, Schema } from "effect"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, ModelSelection, + type ProviderKind, type ProviderStartOptions, } from "@t3tools/contracts"; import { useLocalStorage } from "./hooks/useLocalStorage"; @@ -20,6 +21,9 @@ export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "upda export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +type EnabledProvidersSettings = { + [provider in ProviderKind]: boolean; +}; const withDefaults = < @@ -36,6 +40,10 @@ const withDefaults = export const AppSettingsSchema = Schema.Struct({ claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + enabledProviders: Schema.Struct({ + codex: Schema.Boolean, + claudeAgent: Schema.Boolean, + }).pipe(withDefaults(() => ({ codex: true, claudeAgent: true }))), codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)), @@ -62,6 +70,34 @@ export type AppSettings = typeof AppSettingsSchema.Type; const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); +export function isProviderEnabled( + settings: Pick, + provider: ProviderKind, +): boolean { + return settings.enabledProviders[provider] ?? true; +} + +export function getEnabledProviderOptions( + settings: Pick, +): ProviderKind[] { + return (Object.entries(settings.enabledProviders) as Array<[ProviderKind, boolean]>) + .filter(([, enabled]) => enabled) + .map(([provider]) => provider); +} + +export function patchProviderEnabled( + settings: Pick, + provider: ProviderKind, + enabled: boolean, +): { enabledProviders: EnabledProvidersSettings } { + return { + enabledProviders: { + ...settings.enabledProviders, + [provider]: enabled, + }, + }; +} + function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ace74a5cc8..1c51b48d95 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -120,7 +120,7 @@ import { import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { getProviderStartOptions, useAppSettings } from "../appSettings"; +import { getProviderStartOptions, isProviderEnabled, useAppSettings } from "../appSettings"; import { getCustomModelOptionsByProvider, getCustomModelsByProvider, @@ -210,6 +210,16 @@ const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; +function providerDisplayName(provider: ProviderKind): string { + switch (provider) { + case "claudeAgent": + return "Claude"; + case "codex": + default: + return "Codex"; + } +} + const extendReplacementRangeForTrailingSpace = ( text: string, rangeEnd: number, @@ -610,6 +620,8 @@ export default function ChatView({ threadId }: ChatViewProps) { : null; const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? threadProvider ?? "codex"; + const enabledProviders = settings.enabledProviders; + const isSelectedProviderEnabled = isProviderEnabled(settings, selectedProvider); const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadId, @@ -653,7 +665,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const searchableModelOptions = useMemo( () => AVAILABLE_PROVIDER_OPTIONS.filter( - (option) => lockedProvider === null || option.value === lockedProvider, + (option) => + enabledProviders[option.value] !== false && + (lockedProvider === null || option.value === lockedProvider), ).flatMap((option) => modelOptionsByProvider[option.value].map(({ slug, name }) => ({ provider: option.value, @@ -665,7 +679,7 @@ export default function ChatView({ threadId }: ChatViewProps) { searchProvider: option.label.toLowerCase(), })), ), - [lockedProvider, modelOptionsByProvider], + [enabledProviders, lockedProvider, modelOptionsByProvider], ); const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; @@ -1124,6 +1138,18 @@ export default function ChatView({ threadId }: ChatViewProps) { () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, [selectedProvider, providerStatuses], ); + /** Whether the selected provider is actually usable (enabled + installed + authed). */ + const isSelectedProviderUsable = + isSelectedProviderEnabled && + (activeProviderStatus?.available ?? true) && + activeProviderStatus?.authStatus !== "unauthenticated"; + const selectedProviderIssue: string | null = !isSelectedProviderEnabled + ? `${providerDisplayName(selectedProvider)} is disabled in Settings. Re-enable it to start a turn.` + : activeProviderStatus && !activeProviderStatus.available + ? `${providerDisplayName(selectedProvider)} was not found. Install it or check your PATH.` + : activeProviderStatus?.authStatus === "unauthenticated" + ? `${providerDisplayName(selectedProvider)} is not authenticated. Run its login command to authenticate.` + : null; const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const threadTerminalRuntimeEnv = useMemo(() => { @@ -1207,11 +1233,45 @@ export default function ChatView({ threadId }: ChatViewProps) { const focusComposer = useCallback(() => { composerEditorRef.current?.focusAtEnd(); }, []); + const openSettings = useCallback(() => { + void navigate({ to: "/settings" }); + }, [navigate]); const scheduleComposerFocus = useCallback(() => { window.requestAnimationFrame(() => { focusComposer(); }); }, [focusComposer]); + const guardProviderEnabled = useCallback( + (provider: ProviderKind, targetThreadId: ThreadId | null): boolean => { + if (!isProviderEnabled(settings, provider)) { + setThreadError( + targetThreadId, + `${providerDisplayName(provider)} is disabled in Settings. Re-enable it to start a turn.`, + ); + scheduleComposerFocus(); + return false; + } + const status = providerStatuses.find((s) => s.provider === provider); + if (status && !status.available) { + setThreadError( + targetThreadId, + `${providerDisplayName(provider)} was not found. Install it or check your PATH.`, + ); + scheduleComposerFocus(); + return false; + } + if (status?.authStatus === "unauthenticated") { + setThreadError( + targetThreadId, + `${providerDisplayName(provider)} is not authenticated. Run its login command to authenticate.`, + ); + scheduleComposerFocus(); + return false; + } + return true; + }, + [providerStatuses, scheduleComposerFocus, setThreadError, settings], + ); const addTerminalContextToDraft = useCallback( (selection: TerminalContextSelection) => { if (!activeThread) { @@ -2368,6 +2428,7 @@ export default function ChatView({ threadId }: ChatViewProps) { e?.preventDefault(); const api = readNativeApi(); if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; + if (!guardProviderEnabled(selectedProvider, activeThread.id)) return; if (activePendingProgress) { onAdvanceActivePendingUserInput(); return; @@ -2871,6 +2932,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ) { return; } + if (!guardProviderEnabled(selectedProvider, activeThread.id)) { + return; + } const trimmed = text.trim(); if (!trimmed) { @@ -2966,6 +3030,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProposedPlan, beginSendPhase, forceStickToBottom, + guardProviderEnabled, isConnecting, isSendBusy, isServerThread, @@ -2997,6 +3062,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ) { return; } + if (!guardProviderEnabled(selectedProvider, activeThread.id)) { + return; + } const createdAt = new Date().toISOString(); const nextThreadId = newThreadId(); @@ -3088,6 +3156,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProposedPlan, activeThread, beginSendPhase, + guardProviderEnabled, isConnecting, isSendBusy, isServerThread, @@ -3110,6 +3179,9 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus(); return; } + if (!guardProviderEnabled(provider, activeThread.id)) { + return; + } const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); const nextModelSelection: ModelSelection = { provider, @@ -3121,6 +3193,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [ activeThread, + guardProviderEnabled, lockedProvider, scheduleComposerFocus, setComposerDraftModelSelection, @@ -3757,9 +3830,32 @@ export default function ChatView({ threadId }: ChatViewProps) { ? "Ask for follow-up changes or attach images" : "Ask anything, @tag files/folders, or use / to show available commands" } - disabled={isConnecting || isComposerApprovalState} + disabled={ + isConnecting || isComposerApprovalState || !isSelectedProviderUsable + } /> + {selectedProviderIssue ? ( +
+
+

+ {selectedProviderIssue} +

+

+ Fix the issue in settings or switch to another provider. +

+
+ +
+ ) : null} {/* Bottom toolbar */} {activePendingApproval ? ( @@ -3790,12 +3886,15 @@ export default function ChatView({ threadId }: ChatViewProps) { : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible", )} > - {/* Provider/model picker */} + {/* Provider/model picker — keep interactive even when the + active provider is disabled so the user can switch providers */} {isConnecting || isSendBusy ? "Sending..." : "Refine"} @@ -3991,7 +4091,7 @@ export default function ChatView({ threadId }: ChatViewProps) { type="submit" size="sm" className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" - disabled={isSendBusy || isConnecting} + disabled={isSendBusy || isConnecting || !isSelectedProviderUsable} > {isConnecting || isSendBusy ? "Sending..." : "Implement"} @@ -4003,7 +4103,9 @@ export default function ChatView({ threadId }: ChatViewProps) { variant="default" className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8" aria-label="Implementation actions" - disabled={isSendBusy || isConnecting} + disabled={ + isSendBusy || isConnecting || !isSelectedProviderUsable + } /> } > @@ -4011,7 +4113,9 @@ export default function ChatView({ threadId }: ChatViewProps) { void onImplementPlanInNewThread()} > Implement in a new thread @@ -4025,7 +4129,10 @@ export default function ChatView({ threadId }: ChatViewProps) { type="submit" className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8" disabled={ - isSendBusy || isConnecting || !composerSendState.hasSendableContent + isSendBusy || + isConnecting || + !composerSendState.hasSendableContent || + !isSelectedProviderUsable } aria-label={ isConnecting diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 79fb1ff11e..9b7a9f92dc 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,9 +1,4 @@ -import type { - GitActionProgressEvent, - GitStackedAction, - GitStatusResult, - ThreadId, -} from "@t3tools/contracts"; +import type { GitStackedAction, GitStatusResult, ThreadId } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; @@ -297,74 +292,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }) : null; - useEffect(() => { - const api = readNativeApi(); - if (!api) { - return; - } - - const applyProgressEvent = (event: GitActionProgressEvent) => { - const progress = activeGitActionProgressRef.current; - if (!progress) { - return; - } - if (gitCwd && event.cwd !== gitCwd) { - return; - } - if (progress.actionId !== event.actionId) { - return; - } - - const now = Date.now(); - switch (event.kind) { - case "action_started": - progress.phaseStartedAtMs = now; - progress.hookStartedAtMs = null; - progress.hookName = null; - progress.lastOutputLine = null; - break; - case "phase_started": - progress.title = event.label; - progress.currentPhaseLabel = event.label; - progress.phaseStartedAtMs = now; - progress.hookStartedAtMs = null; - progress.hookName = null; - progress.lastOutputLine = null; - break; - case "hook_started": - progress.title = `Running ${event.hookName}...`; - progress.hookName = event.hookName; - progress.hookStartedAtMs = now; - progress.lastOutputLine = null; - break; - case "hook_output": - progress.lastOutputLine = event.text; - break; - case "hook_finished": - progress.title = progress.currentPhaseLabel ?? "Committing..."; - progress.hookName = null; - progress.hookStartedAtMs = null; - progress.lastOutputLine = null; - break; - case "action_finished": - // Don't clear timestamps here — the HTTP response handler (line 496) - // sets activeGitActionProgressRef to null and shows the success toast. - // Clearing timestamps early causes the "Running for Xs" description - // to disappear before the success state renders, leaving a bare - // "Pushing..." toast in the gap between the WS event and HTTP response. - return; - case "action_failed": - // Same reasoning as action_finished — let the HTTP error handler - // manage the final toast state to avoid a flash of bare title. - return; - } - - updateActiveProgressToast(); - }; - - return api.git.onActionProgress(applyProgressEvent); - }, [gitCwd, updateActiveProgressToast]); - useEffect(() => { const interval = window.setInterval(() => { if (!activeGitActionProgressRef.current) { diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index fad008eba7..f0c8cf8dfd 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -17,6 +17,11 @@ const MODEL_OPTIONS_BY_PROVIDER = { ], } as const satisfies Record>; +const ENABLED_PROVIDERS = { + codex: true, + claudeAgent: true, +} as const satisfies Record; + async function mountPicker(props: { provider: ProviderKind; model: ModelSlug; @@ -31,6 +36,7 @@ async function mountPicker(props: { provider={props.provider} model={props.model} lockedProvider={props.lockedProvider} + enabledProviders={ENABLED_PROVIDERS} modelOptionsByProvider={MODEL_OPTIONS_BY_PROVIDER} triggerVariant={props.triggerVariant} onProviderModelChange={onProviderModelChange} diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index ccf756fec6..1994db8ec6 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,4 +1,4 @@ -import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; +import { type ModelSlug, type ProviderKind, type ServerProviderStatus } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; import { memo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; @@ -49,10 +49,43 @@ function providerIconClassName( return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName; } +/** Derive a short status label for a provider that isn't usable. */ +function providerIssueLabel( + status: ServerProviderStatus | undefined, + enabledBySettings: boolean, +): string | null { + if (!enabledBySettings) return "Disabled"; + if (!status) return null; + if (!status.available) return "Not found"; + if (status.authStatus === "unauthenticated") return "Unauthed"; + return null; +} + +/** Whether a provider is actually usable (installed + authenticated + enabled). */ +function isProviderUsable( + status: ServerProviderStatus | undefined, + enabledBySettings: boolean, +): boolean { + if (!enabledBySettings) return false; + if (!status) return true; // status not loaded yet — assume usable + if (!status.available) return false; + if (status.authStatus === "unauthenticated") return false; + return true; +} + +function providerMetaLabel( + status: ServerProviderStatus | undefined, + enabledBySettings: boolean, +): string | null { + return providerIssueLabel(status, enabledBySettings); +} + export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: ModelSlug; lockedProvider: ProviderKind | null; + enabledProviders: Record; + providerStatuses?: ReadonlyArray; modelOptionsByProvider: Record>; activeProviderIconClassName?: string; compact?: boolean; @@ -63,12 +96,24 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { }) { const [isMenuOpen, setIsMenuOpen] = useState(false); const activeProvider = props.lockedProvider ?? props.provider; + const activeProviderStatus = props.providerStatuses?.find((s) => s.provider === activeProvider); + const showActiveProviderChevron = + props.enabledProviders[activeProvider] !== false && + activeProviderStatus !== undefined && + activeProviderStatus.status === "ready" && + activeProviderStatus.available && + activeProviderStatus.authStatus === "authenticated"; + const isActiveProviderUsable = isProviderUsable( + activeProviderStatus, + props.enabledProviders[activeProvider] !== false, + ); const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; const selectedModelLabel = selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; const handleModelChange = (provider: ProviderKind, value: string) => { - if (props.disabled) return; + const status = props.providerStatuses?.find((s) => s.provider === provider); + if (!isProviderUsable(status, props.enabledProviders[provider] !== false)) return; if (!value) return; const resolvedModel = resolveSelectableModel( provider, @@ -80,11 +125,15 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { setIsMenuOpen(false); }; + // Allow opening the menu even when the active provider is unusable, + // so the user can switch to a working provider. + const canOpenMenu = !props.disabled || !isActiveProviderUsable; + return ( { - if (props.disabled) { + if (!canOpenMenu) { setIsMenuOpen(false); return; } @@ -99,9 +148,10 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { className={cn( "min-w-0 justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 [&_svg]:mx-0", props.compact ? "max-w-42 shrink-0" : "max-w-48 shrink sm:max-w-56 sm:px-3", + !isActiveProviderUsable && "text-destructive/70 hover:text-destructive", props.triggerClassName, )} - disabled={props.disabled} + disabled={!canOpenMenu} /> } > @@ -115,12 +165,16 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { aria-hidden="true" className={cn( "size-4 shrink-0", - providerIconClassName(activeProvider, "text-muted-foreground/70"), - props.activeProviderIconClassName, + !isActiveProviderUsable + ? "text-destructive/50" + : providerIconClassName(activeProvider, "text-muted-foreground/70"), + isActiveProviderUsable && props.activeProviderIconClassName, )} /> {selectedModelLabel} -