diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index cbb97a807e..845c7ef16d 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -14,7 +14,7 @@ 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 { @@ -592,12 +592,25 @@ export const checkClaudeProviderStatus: Effect.Effect< 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 = yield* Fiber.join(statusesFiber); + const statusesRef = yield* Ref.make(initialStatuses); return { - getStatuses: Fiber.join(statusesFiber), + getStatuses: Ref.get(statusesRef), + refreshStatuses: Effect.flatMap(runProviderChecks, (statuses) => + Ref.set(statusesRef, statuses).pipe(Effect.as(statuses)), + ), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Services/ProviderHealth.ts b/apps/server/src/provider/Services/ProviderHealth.ts index ec3b2d318d..14ba039680 100644 --- a/apps/server/src/provider/Services/ProviderHealth.ts +++ b/apps/server/src/provider/Services/ProviderHealth.ts @@ -15,6 +15,11 @@ 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>; } export class ProviderHealth extends ServiceMap.Service()( diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 9c6adfeba9..84af79cee6 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -76,6 +76,7 @@ const defaultProviderStatuses: ReadonlyArray = [ const defaultProviderHealthService: ProviderHealthShape = { getStatuses: Effect.succeed(defaultProviderStatuses), + refreshStatuses: Effect.succeed(defaultProviderStatuses), }; class MockTerminalManager implements TerminalManagerShape { @@ -1111,6 +1112,38 @@ 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), + }, + }); + 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("returns error for unknown methods", async () => { server = await createTestServer({ cwd: "/test" }); const addr = server.address(); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index e22c23988b..fc086bf931 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; @@ -877,6 +876,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< availableEditors, }; + case WS_METHODS.serverRefreshProviderStatuses: { + const providers = yield* providerHealth.refreshStatuses; + return { providers }; + } + case WS_METHODS.serverUpsertKeybinding: { const body = stripRequestTag(request.body); const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body); diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 26d231537d..865284cb34 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -4,13 +4,16 @@ import { describe, expect, it } from "vitest"; import { AppSettingsSchema, DEFAULT_TIMESTAMP_FORMAT, + getEnabledProviderOptions, getAppModelOptions, getCustomModelOptionsByProvider, getCustomModelsByProvider, getCustomModelsForProvider, getDefaultCustomModelsForProvider, + isProviderEnabled, MODEL_PROVIDER_SETTINGS, normalizeCustomModelSlugs, + patchProviderEnabled, patchCustomModels, resolveAppModelSelection, } from "./appSettings"; @@ -197,6 +200,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 14b6a6a92d..83509d9d1c 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -18,6 +18,9 @@ export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]) export type TimestampFormat = typeof TimestampFormat.Type; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels"; +type EnabledProvidersSettings = { + [provider in ProviderKind]: boolean; +}; export type ProviderCustomModelConfig = { provider: ProviderKind; settingsKey: CustomModelSettingsKey; @@ -47,6 +50,10 @@ const withDefaults = ); export const AppSettingsSchema = Schema.Struct({ + 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)), @@ -116,6 +123,34 @@ export function normalizeCustomModelSlugs( return normalizedModels; } +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 e628f6ea6a..5eb13221c1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -122,6 +122,7 @@ import { readNativeApi } from "~/nativeApi"; import { getCustomModelOptionsByProvider, getCustomModelsByProvider, + isProviderEnabled, resolveAppModelSelection, useAppSettings, } from "../appSettings"; @@ -204,6 +205,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, @@ -593,6 +604,8 @@ export default function ChatView({ threadId }: ChatViewProps) { ? (sessionProvider ?? selectedProviderByThreadId ?? null) : null; const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; + const enabledProviders = settings.enabledProviders; + const isSelectedProviderEnabled = isProviderEnabled(settings, selectedProvider); const baseThreadModel = resolveModelSlugForProvider( selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), @@ -643,7 +656,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, @@ -655,7 +670,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"; @@ -1109,6 +1124,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(() => { @@ -1188,11 +1215,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) { @@ -2346,6 +2407,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; @@ -2847,6 +2909,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ) { return; } + if (!guardProviderEnabled(selectedProvider, activeThread.id)) { + return; + } const trimmed = text.trim(); if (!trimmed) { @@ -2945,6 +3010,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProposedPlan, beginSendPhase, forceStickToBottom, + guardProviderEnabled, isConnecting, isSendBusy, isServerThread, @@ -2976,6 +3042,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ) { return; } + if (!guardProviderEnabled(selectedProvider, activeThread.id)) { + return; + } const createdAt = new Date().toISOString(); const nextThreadId = newThreadId(); @@ -3074,6 +3143,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProposedPlan, activeThread, beginSendPhase, + guardProviderEnabled, isConnecting, isSendBusy, isServerThread, @@ -3096,6 +3166,9 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus(); return; } + if (!guardProviderEnabled(provider, activeThread.id)) { + return; + } const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); setComposerDraftProvider(activeThread.id, provider); setComposerDraftModel(activeThread.id, resolvedModel); @@ -3104,6 +3177,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [ activeThread, + guardProviderEnabled, lockedProvider, scheduleComposerFocus, setComposerDraftModel, @@ -3733,9 +3807,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 ? ( @@ -3766,12 +3863,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"} @@ -3964,7 +4065,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"} @@ -3976,7 +4077,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 + } /> } > @@ -3984,7 +4087,9 @@ export default function ChatView({ threadId }: ChatViewProps) { void onImplementPlanInNewThread()} > Implement in a new thread @@ -3998,7 +4103,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/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 1694b374c8..75ff21c066 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -30,6 +30,10 @@ async function mountPicker(props: { provider={props.provider} model={props.model} lockedProvider={props.lockedProvider} + enabledProviders={{ + codex: true, + claudeAgent: true, + }} modelOptionsByProvider={MODEL_OPTIONS_BY_PROVIDER} onProviderModelChange={onProviderModelChange} />, diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 95f27f39cd..a8f369997b 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 ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; @@ -48,10 +48,36 @@ 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; +} + export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: ModelSlug; lockedProvider: ProviderKind | null; + enabledProviders: Record; + providerStatuses?: ReadonlyArray; modelOptionsByProvider: Record>; activeProviderIconClassName?: string; compact?: boolean; @@ -60,12 +86,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, @@ -77,11 +115,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; } @@ -96,8 +138,9 @@ 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", )} - disabled={props.disabled} + disabled={!canOpenMenu} /> } > @@ -111,12 +154,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} -