From c6f71541baedce2724e944208d9c33178d924fd4 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 12 Apr 2026 19:34:27 -0500 Subject: [PATCH 1/2] Add provider availability settings and picker filtering - Move provider readiness checks to on-demand config loading - Add authentication settings for Codex, Claude, and OpenClaw - Filter thread/provider pickers to only show selectable providers --- .../src/provider/Layers/ProviderHealth.ts | 26 +- apps/web/src/components/ChatView.tsx | 39 +- .../chat/ProviderModelPicker.browser.tsx | 25 + .../components/chat/ProviderModelPicker.tsx | 21 +- apps/web/src/components/home/home-utils.ts | 2 + apps/web/src/lib/providerAvailability.test.ts | 82 ++ apps/web/src/lib/providerAvailability.ts | 68 + apps/web/src/routes/_chat.settings.tsx | 1175 +++++++++-------- 8 files changed, 830 insertions(+), 608 deletions(-) create mode 100644 apps/web/src/lib/providerAvailability.test.ts create mode 100644 apps/web/src/lib/providerAvailability.ts diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 2b24becb4..69f4e286d 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -1,8 +1,7 @@ /** * ProviderHealthLive - Startup-time provider health checks. * - * Performs one-time provider readiness probes when the server starts and - * keeps the resulting snapshot in memory for `server.getConfig`. + * Performs provider readiness probes on demand for `server.getConfig`. * * Uses effect's ChildProcessSpawner to run CLI probes natively. * @@ -18,7 +17,6 @@ import { Array, Data, Effect, - Fiber, FileSystem, Layer, Option, @@ -683,18 +681,16 @@ const checkOpenClawProviderStatus: Effect.Effect + getSelectableThreadProviders({ + statuses: providerStatuses, + openclawGatewayUrl: settings.openclawGatewayUrl, + }), + [providerStatuses, settings.openclawGatewayUrl], + ); const hasThreadStarted = Boolean( activeThread && (activeThread.latestTurn !== null || @@ -867,7 +881,12 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? selectedProviderByThreadId ?? null) : null; - const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; + const selectedProvider: ProviderKind = + lockedProvider ?? + resolveThreadProviderSelection({ + preferredProvider: selectedProviderByThreadId, + selectableProviders, + }); const baseThreadModel = resolveModelSlugForProvider( selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), @@ -907,20 +926,18 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); const searchableModelOptions = useMemo( () => - AVAILABLE_PROVIDER_OPTIONS.filter( - (option) => lockedProvider === null || option.value === lockedProvider, - ).flatMap((option) => - modelOptionsByProvider[option.value].map(({ slug, name }) => ({ - provider: option.value, - providerLabel: option.label, + (lockedProvider !== null ? [lockedProvider] : selectableProviders).flatMap((provider) => + modelOptionsByProvider[provider].map(({ slug, name }) => ({ + provider, + providerLabel: getThreadProviderLabel(provider), slug, name, searchSlug: slug.toLowerCase(), searchName: name.toLowerCase(), - searchProvider: option.label.toLowerCase(), + searchProvider: getThreadProviderLabel(provider).toLowerCase(), })), ), - [lockedProvider, modelOptionsByProvider], + [lockedProvider, modelOptionsByProvider, selectableProviders], ); const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; @@ -1535,7 +1552,6 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { }; }, []); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; - const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; const activeProviderStatus = useMemo( () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, [selectedProvider, providerStatuses], @@ -5319,6 +5335,7 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { provider={selectedProvider} model={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} + availableProviders={selectableProviders} modelOptionsByProvider={modelOptionsByProvider} {...(composerProviderState.modelPickerIconClassName ? { diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index c2f9e94e4..c61baa0be 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -22,6 +22,7 @@ async function mountPicker(props: { provider: ProviderKind; model: ModelSlug; lockedProvider: ProviderKind | null; + availableProviders?: ReadonlyArray; }) { const host = document.createElement("div"); document.body.append(host); @@ -31,6 +32,7 @@ async function mountPicker(props: { provider={props.provider} model={props.model} lockedProvider={props.lockedProvider} + availableProviders={props.availableProviders ?? ["codex", "claudeAgent", "openclaw"]} modelOptionsByProvider={MODEL_OPTIONS_BY_PROVIDER} onProviderModelChange={onProviderModelChange} />, @@ -56,6 +58,7 @@ describe("ProviderModelPicker", () => { provider: "claudeAgent", model: "claude-opus-4-6", lockedProvider: null, + availableProviders: ["codex", "claudeAgent"], }); try { @@ -114,4 +117,26 @@ describe("ProviderModelPicker", () => { await mounted.cleanup(); } }); + + it("only shows authenticated providers when switching is allowed", async () => { + const mounted = await mountPicker({ + provider: "codex", + model: "gpt-5-codex", + lockedProvider: null, + availableProviders: ["codex"], + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Codex"); + expect(text).toContain("GPT-5.3 Codex"); + expect(text).not.toContain("Claude Code"); + }); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 1b1fc8db9..0b4e6dfc9 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -17,14 +17,7 @@ import { } from "../ui/menu"; import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenClawIcon, OpenCodeIcon } from "../Icons"; import { cn } from "~/lib/utils"; - -function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { - value: ProviderKind; - label: string; - available: true; -} { - return option.available; -} +import { getThreadProviderLabel } from "~/lib/providerAvailability"; const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, @@ -33,7 +26,6 @@ const PROVIDER_ICON_BY_PROVIDER: Record = { cursor: CursorIcon, }; -export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); const COMING_SOON_PROVIDER_OPTIONS = [ { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, @@ -50,13 +42,14 @@ function providerIconClassName( } function getProviderLabel(provider: ProviderKind): string { - return AVAILABLE_PROVIDER_OPTIONS.find((option) => option.value === provider)?.label ?? provider; + return getThreadProviderLabel(provider); } export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: ModelSlug; lockedProvider: ProviderKind | null; + availableProviders: ReadonlyArray; modelOptionsByProvider: Record>; activeProviderIconClassName?: string; compact?: boolean; @@ -65,6 +58,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { }) { const [isMenuOpen, setIsMenuOpen] = useState(false); const activeProvider = props.lockedProvider ?? props.provider; + const visibleProviders = + props.lockedProvider !== null ? [props.lockedProvider] : props.availableProviders; const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; const selectedModelLabel = selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; @@ -147,7 +142,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ) : ( <> - {AVAILABLE_PROVIDER_OPTIONS.map((option, index) => { + {visibleProviders.map((provider, index) => { + const option = { + value: provider, + label: getThreadProviderLabel(provider), + }; const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; return ( diff --git a/apps/web/src/components/home/home-utils.ts b/apps/web/src/components/home/home-utils.ts index 64ffe30e5..229fca532 100644 --- a/apps/web/src/components/home/home-utils.ts +++ b/apps/web/src/components/home/home-utils.ts @@ -10,6 +10,8 @@ export function getProviderLabel(provider: ServerProviderStatus["provider"]) { return "Claude"; case "codex": return "Codex"; + case "openclaw": + return "OpenClaw"; } } diff --git a/apps/web/src/lib/providerAvailability.test.ts b/apps/web/src/lib/providerAvailability.test.ts new file mode 100644 index 000000000..53c4fb68e --- /dev/null +++ b/apps/web/src/lib/providerAvailability.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import type { ProviderKind, ServerProviderStatus } from "@okcode/contracts"; +import { + getSelectableThreadProviders, + isProviderReadyForThreadSelection, + resolveThreadProviderSelection, +} from "./providerAvailability"; + +function makeStatus( + provider: ProviderKind, + overrides: Partial = {}, +): ServerProviderStatus { + return { + provider, + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: "2026-04-12T12:00:00.000Z", + ...overrides, + }; +} + +describe("providerAvailability", () => { + it("allows ready authenticated CLI providers", () => { + expect( + isProviderReadyForThreadSelection({ + provider: "codex", + statuses: [makeStatus("codex")], + }), + ).toBe(true); + }); + + it("allows ready providers with unknown auth when auth is handled externally", () => { + expect( + isProviderReadyForThreadSelection({ + provider: "codex", + statuses: [makeStatus("codex", { authStatus: "unknown" })], + }), + ).toBe(true); + }); + + it("blocks providers that are explicitly unauthenticated", () => { + expect( + isProviderReadyForThreadSelection({ + provider: "claudeAgent", + statuses: [makeStatus("claudeAgent", { status: "error", authStatus: "unauthenticated" })], + }), + ).toBe(false); + }); + + it("treats configured OpenClaw as selectable even when server auth state is unknown", () => { + expect( + isProviderReadyForThreadSelection({ + provider: "openclaw", + statuses: [], + openclawGatewayUrl: "ws://localhost:8080", + }), + ).toBe(true); + }); + + it("returns selectable providers in stable picker order", () => { + expect( + getSelectableThreadProviders({ + statuses: [ + makeStatus("openclaw", { authStatus: "unknown" }), + makeStatus("codex"), + makeStatus("claudeAgent", { status: "error", authStatus: "unauthenticated" }), + ], + }), + ).toEqual(["codex", "openclaw"]); + }); + + it("falls back to the first selectable provider when the preferred one is unavailable", () => { + expect( + resolveThreadProviderSelection({ + preferredProvider: "claudeAgent", + selectableProviders: ["codex", "openclaw"], + }), + ).toBe("codex"); + }); +}); diff --git a/apps/web/src/lib/providerAvailability.ts b/apps/web/src/lib/providerAvailability.ts new file mode 100644 index 000000000..e2eab8233 --- /dev/null +++ b/apps/web/src/lib/providerAvailability.ts @@ -0,0 +1,68 @@ +import type { ProviderKind, ServerProviderStatus } from "@okcode/contracts"; + +const THREAD_PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "openclaw"]; + +const THREAD_PROVIDER_LABELS: Record = { + codex: "Codex", + claudeAgent: "Claude Code", + openclaw: "OpenClaw", +}; + +export function getThreadProviderLabel(provider: ProviderKind): string { + return THREAD_PROVIDER_LABELS[provider]; +} + +export function getProviderStatusByKind( + statuses: ReadonlyArray, + provider: ProviderKind, +): ServerProviderStatus | null { + return statuses.find((status) => status.provider === provider) ?? null; +} + +export function isProviderReadyForThreadSelection(input: { + provider: ProviderKind; + statuses: ReadonlyArray; + openclawGatewayUrl?: string | null | undefined; +}): boolean { + const status = getProviderStatusByKind(input.statuses, input.provider); + + if (input.provider === "openclaw") { + if (status?.status === "ready" && status.available) { + return true; + } + return (input.openclawGatewayUrl ?? "").trim().length > 0; + } + + if (!status) { + return false; + } + + return status.available && status.status === "ready" && status.authStatus !== "unauthenticated"; +} + +export function getSelectableThreadProviders(input: { + statuses: ReadonlyArray; + openclawGatewayUrl?: string | null | undefined; +}): ProviderKind[] { + return THREAD_PROVIDER_ORDER.filter((provider) => + isProviderReadyForThreadSelection({ + provider, + statuses: input.statuses, + openclawGatewayUrl: input.openclawGatewayUrl, + }), + ); +} + +export function resolveThreadProviderSelection(input: { + preferredProvider?: ProviderKind | null | undefined; + selectableProviders: ReadonlyArray; +}): ProviderKind { + if ( + input.preferredProvider && + input.selectableProviders.includes(input.preferredProvider) + ) { + return input.preferredProvider; + } + + return input.selectableProviders[0] ?? input.preferredProvider ?? "codex"; +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 842e5d1d2..b70d2ebcd 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -11,7 +11,9 @@ import { Loader2Icon, PaletteIcon, PlusIcon, + RefreshCwIcon, RotateCcwIcon, + ShieldCheckIcon, SkipForwardIcon, SmartphoneIcon, Undo2Icon, @@ -28,6 +30,7 @@ import { type KeybindingRule, type ProjectId, type ProviderKind, + type ServerProviderStatus, DEFAULT_GIT_TEXT_GENERATION_MODEL, } from "@okcode/contracts"; import { getModelOptions, normalizeModelSlug } from "@okcode/shared/model"; @@ -97,17 +100,27 @@ import { type CustomThemeData, } from "../lib/customTheme"; import { openUrlInAppBrowser } from "../lib/openUrlInAppBrowser"; +import { + getSelectableThreadProviders, + isProviderReadyForThreadSelection, +} from "../lib/providerAvailability"; import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { cn } from "../lib/utils"; import { ensureNativeApi, readNativeApi } from "../nativeApi"; import { useStore } from "../store"; import { PairingLink } from "../components/mobile/PairingLink"; +import { + getProviderLabel as getProviderStatusLabelName, + getProviderStatusDescription, + getProviderStatusHeading, +} from "../components/chat/providerStatusPresentation"; // --------------------------------------------------------------------------- // Settings navigation sections // --------------------------------------------------------------------------- type SettingsSectionId = | "general" + | "authentication" | "hotkeys" | "environment" | "git" @@ -125,6 +138,11 @@ interface SettingsNavItem { function useSettingsNavItems(): SettingsNavItem[] { return [ { id: "general", label: "General", icon: }, + { + id: "authentication", + label: "Authentication", + icon: , + }, { id: "hotkeys", label: "Hotkeys", icon: }, { id: "environment", label: "Environment", icon: }, { id: "git", label: "Git", icon: }, @@ -336,6 +354,149 @@ const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ }, ]; +const PROVIDER_AUTH_GUIDES: Record< + ProviderKind, + { + installCmd?: string; + authCmd?: string; + verifyCmd?: string; + note: string; + } +> = { + codex: { + installCmd: "npm install -g @openai/codex", + authCmd: "codex login", + verifyCmd: "codex login status", + note: + "Codex stays available in thread creation when the CLI is ready and its auth is either confirmed or delegated to a custom model provider.", + }, + claudeAgent: { + installCmd: "npm install -g @anthropic-ai/claude-code", + authCmd: "claude auth login", + verifyCmd: "claude auth status", + note: "Claude Code must be installed and signed in before it appears in the thread picker.", + }, + openclaw: { + verifyCmd: "Test Connection", + note: + "OpenClaw uses the gateway URL and password below rather than a local CLI login. A configured gateway unlocks it for new-thread selection.", + }, +}; + +function getAuthenticationBadgeCopy(input: { + status: ServerProviderStatus | null; + provider: ProviderKind; + openclawGatewayUrl: string; +}): { + tone: "success" | "warning" | "error"; + label: string; +} { + if ( + isProviderReadyForThreadSelection({ + provider: input.provider, + statuses: input.status ? [input.status] : [], + openclawGatewayUrl: input.openclawGatewayUrl, + }) + ) { + return { tone: "success", label: "Available in thread picker" }; + } + + if (input.status?.authStatus === "unauthenticated") { + return { tone: "error", label: "Sign-in required" }; + } + + if (input.provider === "openclaw" && input.openclawGatewayUrl.trim().length === 0) { + return { tone: "warning", label: "Gateway not configured" }; + } + + if (input.status?.available === false || input.status?.status === "error") { + return { tone: "error", label: "Unavailable" }; + } + + return { tone: "warning", label: "Needs verification" }; +} + +function AuthenticationStatusCard({ + provider, + status, + openclawGatewayUrl, +}: { + provider: ProviderKind; + status: ServerProviderStatus | null; + openclawGatewayUrl: string; +}) { + const guide = PROVIDER_AUTH_GUIDES[provider]; + const badge = getAuthenticationBadgeCopy({ status, provider, openclawGatewayUrl }); + const badgeClassName = + badge.tone === "success" + ? "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" + : badge.tone === "error" + ? "border-red-500/25 bg-red-500/10 text-red-700 dark:text-red-300" + : "border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-300"; + const heading = + status !== null + ? getProviderStatusHeading(status) + : provider === "openclaw" && openclawGatewayUrl.trim().length > 0 + ? "OpenClaw gateway is configured locally" + : `${getProviderStatusLabelName(provider)} needs configuration`; + const description = + status !== null + ? getProviderStatusDescription(status) + : provider === "openclaw" && openclawGatewayUrl.trim().length > 0 + ? "OpenClaw is configured in local settings. Use Test Connection below to verify the gateway before starting a thread." + : guide.note; + + return ( +
+
+
+
+

+ {getProviderStatusLabelName(provider)} +

+ + {badge.label} + +
+

{heading}

+

{description}

+
+ {status?.checkedAt ? ( + + Checked {new Date(status.checkedAt).toLocaleString()} + + ) : null} +
+ +
+
+
Install
+ + {guide.installCmd ?? "Configured in-app"} + +
+
+
Authenticate
+ + {guide.authCmd ?? "Use gateway password"} + +
+
+
Verify
+ {guide.verifyCmd ?? "N/A"} +
+
+ +

{guide.note}

+
+ ); +} + function SettingsSection({ title, description, @@ -675,6 +836,11 @@ function SettingsRouteView() { const claudeBinaryPath = settings.claudeBinaryPath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; + const providerStatuses = serverConfigQuery.data?.providers ?? []; + const selectableProviders = getSelectableThreadProviders({ + statuses: providerStatuses, + openclawGatewayUrl: settings.openclawGatewayUrl, + }); const gitTextGenerationModelOptions = getAppModelOptions( "codex", @@ -833,6 +999,10 @@ function SettingsRouteView() { [queryClient], ); + const refreshProviderStatuses = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); + }, [queryClient]); + const saveGlobalEnvironmentVariables = useCallback( async (entries: ReadonlyArray<{ key: string; value: string }>) => { const api = ensureNativeApi(); @@ -2185,390 +2355,60 @@ function SettingsRouteView() { )} - {activeSection === "hotkeys" && ( - - )} - - {activeSection === "environment" && ( + {activeSection === "authentication" && ( void refreshProviderStatuses()} + > + + Refresh status + + } > - Failed to load saved variables:{" "} - {getErrorMessage(globalEnvironmentVariablesQuery.error)} - - ) : globalEnvironmentVariablesQuery.isFetching ? ( - Loading saved variables... - ) : globalEnvironmentVariablesQuery.data?.entries.length ? ( - - {globalEnvironmentVariablesQuery.data.entries.length} saved variables - - ) : ( - No global variables saved yet. - ) - } - > - - - - - {selectedProject.name} · {selectedProject.cwd} - - ) : ( - Open a project to edit project variables. - ) - } - control={ - projects.length > 0 ? ( - - ) : ( - - No projects available. - - ) - } + title="Thread picker availability" + description="These checks decide which providers show up in the thread composer before a provider is locked in." + status={`${selectableProviders.length} provider${selectableProviders.length === 1 ? "" : "s"} currently selectable`} > - - - - )} - - {activeSection === "git" && ( - - - updateSettings({ - rebaseBeforeCommit: defaults.rebaseBeforeCommit, - }) +
+ {(["codex", "claudeAgent", "openclaw"] as const).map((provider) => ( + status.provider === provider) ?? null } + openclawGatewayUrl={settings.openclawGatewayUrl} /> - ) : null - } - control={ - - updateSettings({ - rebaseBeforeCommit: Boolean(checked), - }) - } - aria-label="Rebase onto the default branch before committing" - /> - } - /> - - )} + ))} +
+
- {activeSection === "models" && ( - + label="provider installs" + onClick={() => { updateSettings({ - textGenerationModel: defaults.textGenerationModel, - }) - } - /> - ) : null - } - control={ - - } - /> - - 0 ? ( - { - updateSettings({ - customCodexModels: defaults.customCodexModels, - customClaudeModels: defaults.customClaudeModels, - }); - setCustomModelErrorByProvider({}); - setShowAllCustomModels(false); - }} - /> - ) : null - } - > -
-
- - { - const value = event.target.value; - setCustomModelInputByProvider((existing) => ({ - ...existing, - [selectedCustomModelProvider]: value, - })); - if (selectedCustomModelError) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [selectedCustomModelProvider]: null, - })); - } - }} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - addCustomModel(selectedCustomModelProvider); - }} - placeholder={selectedCustomModelProviderSettings.example} - spellCheck={false} - /> - -
- - {selectedCustomModelError ? ( -

- {selectedCustomModelError} -

- ) : null} - - {totalCustomModels > 0 ? ( -
-
- {visibleCustomModelRows.map((row) => ( -
- - {row.providerTitle} - - - {row.slug} - - -
- ))} -
- - {savedCustomModelRows.length > 5 ? ( - - ) : null} -
- ) : null} -
-
-
- )} - - {activeSection === "mobile" && !isMobileShell && ( - - -
- -
-
-
- )} - - {activeSection === "advanced" && ( - - { - updateSettings({ - claudeBinaryPath: defaults.claudeBinaryPath, - codexBinaryPath: defaults.codexBinaryPath, - codexHomePath: defaults.codexHomePath, - }); - setOpenInstallProviders({ - codex: false, - claudeAgent: false, - openclaw: false, - }); - }} + claudeBinaryPath: defaults.claudeBinaryPath, + codexBinaryPath: defaults.codexBinaryPath, + codexHomePath: defaults.codexHomePath, + }); + setOpenInstallProviders({ + codex: false, + claudeAgent: false, + openclaw: false, + }); + }} /> ) : null } @@ -2697,9 +2537,13 @@ function SettingsRouteView() { 0 + ? `Configured for ${settings.openclawGatewayUrl}` + : "Not configured" + } resetAction={ - settings.openclawGatewayUrl !== defaults.openclawGatewayUrl || - settings.openclawPassword !== defaults.openclawPassword ? ( + isOpenClawSettingsDirty ? ( @@ -2755,7 +2599,6 @@ function SettingsRouteView() { - {/* Test Connection Button */}
- {/* Debug / Results Panel */} - {openclawTestResult && ( + {openclawTestResult ? (
- {/* Overall status header */}
{openclawTestResult.success ? ( @@ -2807,228 +2648,420 @@ function SettingsRouteView() {
- {/* Step-by-step results */} - {openclawTestResult.steps.length > 0 && ( + {openclawTestResult.steps.length > 0 ? (
{openclawTestResult.steps.map((step) => (
- {step.status === "pass" && ( + {step.status === "pass" ? ( - )} - {step.status === "fail" && ( + ) : null} + {step.status === "fail" ? ( - )} - {step.status === "skip" && ( + ) : null} + {step.status === "skip" ? ( - )} + ) : null}
{step.name} - + {step.durationMs}ms
- {step.detail && ( + {step.detail ? ( {step.detail} - )} + ) : null}
))}
- )} - - {/* Server info */} - {openclawTestResult.serverInfo && ( -
- - Server Info - -
- {openclawTestResult.serverInfo.version && ( -
- Version:{" "} - - {openclawTestResult.serverInfo.version} - -
- )} - {openclawTestResult.serverInfo.sessionId && ( -
- Session:{" "} - - {openclawTestResult.serverInfo.sessionId} - -
- )} -
-
- )} - - {openclawTestResult.diagnostics && ( -
- - Debugging Context - -
- {openclawTestResult.diagnostics.normalizedUrl && ( -
- Endpoint:{" "} - - {openclawTestResult.diagnostics.normalizedUrl} - -
- )} - {openclawTestResult.diagnostics.hostKind && ( -
- Host type:{" "} - - {describeOpenclawGatewayHostKind( - openclawTestResult.diagnostics.hostKind, - )} - -
- )} - {openclawTestResult.diagnostics.resolvedAddresses.length > 0 && ( -
- Resolved:{" "} - - {openclawTestResult.diagnostics.resolvedAddresses.join( - ", ", - )} - -
- )} - {describeOpenclawGatewayHealthStatus(openclawTestResult) && ( -
- Health probe:{" "} - - {describeOpenclawGatewayHealthStatus(openclawTestResult)} - - {openclawTestResult.diagnostics.healthUrl && ( - <> - {" "} - at{" "} - - {openclawTestResult.diagnostics.healthUrl} - - - )} -
- )} - {openclawTestResult.diagnostics.socketCloseCode !== undefined && ( -
- Socket close:{" "} - - {openclawTestResult.diagnostics.socketCloseCode} - {openclawTestResult.diagnostics.socketCloseReason - ? ` (${openclawTestResult.diagnostics.socketCloseReason})` - : ""} - -
- )} - {openclawTestResult.diagnostics.socketError && ( -
- Socket error:{" "} - - {openclawTestResult.diagnostics.socketError} - -
- )} - {openclawTestResult.diagnostics.gatewayErrorCode && ( -
- Gateway error code:{" "} - - {openclawTestResult.diagnostics.gatewayErrorCode} - -
- )} - {openclawTestResult.diagnostics.gatewayErrorDetailCode && ( -
- Gateway detail code:{" "} - - {openclawTestResult.diagnostics.gatewayErrorDetailCode} - -
- )} - {openclawTestResult.diagnostics.gatewayErrorDetailReason && ( -
- Gateway detail reason:{" "} - - {openclawTestResult.diagnostics.gatewayErrorDetailReason} - -
- )} - {openclawTestResult.diagnostics.gatewayRecommendedNextStep && ( -
- Gateway next step:{" "} - - {openclawTestResult.diagnostics.gatewayRecommendedNextStep} - -
- )} - {openclawTestResult.diagnostics.gatewayCanRetryWithDeviceToken !== - undefined && ( -
- Device-token retry available:{" "} - - {openclawTestResult.diagnostics - .gatewayCanRetryWithDeviceToken - ? "Yes" - : "No"} - -
- )} - {openclawTestResult.diagnostics.observedNotifications.length > - 0 && ( -
- Gateway events:{" "} - - {openclawTestResult.diagnostics.observedNotifications.join( - ", ", - )} - -
- )} -
-
- )} - - {openclawTestResult.diagnostics && - openclawTestResult.diagnostics.hints.length > 0 && ( -
- - Troubleshooting - -
    - {openclawTestResult.diagnostics.hints.map((hint) => ( -
  • - - {hint} -
  • - ))} -
-
- )} + ) : null} - {/* Error summary */} {openclawTestResult.error && - !openclawTestResult.steps.some((s) => s.status === "fail") && ( -
- {openclawTestResult.error} -
- )} + !openclawTestResult.steps.some((step) => step.status === "fail") ? ( +
+ {openclawTestResult.error} +
+ ) : null}
- )} + ) : null}
+
+ )} + + {activeSection === "hotkeys" && ( + + )} + {activeSection === "environment" && ( + + + Failed to load saved variables:{" "} + {getErrorMessage(globalEnvironmentVariablesQuery.error)} + + ) : globalEnvironmentVariablesQuery.isFetching ? ( + Loading saved variables... + ) : globalEnvironmentVariablesQuery.data?.entries.length ? ( + + {globalEnvironmentVariablesQuery.data.entries.length} saved variables + + ) : ( + No global variables saved yet. + ) + } + > + + + + + {selectedProject.name} · {selectedProject.cwd} + + ) : ( + Open a project to edit project variables. + ) + } + control={ + projects.length > 0 ? ( + + ) : ( + + No projects available. + + ) + } + > + + + + )} + + {activeSection === "git" && ( + + + updateSettings({ + rebaseBeforeCommit: defaults.rebaseBeforeCommit, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + rebaseBeforeCommit: Boolean(checked), + }) + } + aria-label="Rebase onto the default branch before committing" + /> + } + /> + + )} + + {activeSection === "models" && ( + + + updateSettings({ + textGenerationModel: defaults.textGenerationModel, + }) + } + /> + ) : null + } + control={ + + } + /> + + 0 ? ( + { + updateSettings({ + customCodexModels: defaults.customCodexModels, + customClaudeModels: defaults.customClaudeModels, + }); + setCustomModelErrorByProvider({}); + setShowAllCustomModels(false); + }} + /> + ) : null + } + > +
+
+ + { + const value = event.target.value; + setCustomModelInputByProvider((existing) => ({ + ...existing, + [selectedCustomModelProvider]: value, + })); + if (selectedCustomModelError) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [selectedCustomModelProvider]: null, + })); + } + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + addCustomModel(selectedCustomModelProvider); + }} + placeholder={selectedCustomModelProviderSettings.example} + spellCheck={false} + /> + +
+ + {selectedCustomModelError ? ( +

+ {selectedCustomModelError} +

+ ) : null} + + {totalCustomModels > 0 ? ( +
+
+ {visibleCustomModelRows.map((row) => ( +
+ + {row.providerTitle} + + + {row.slug} + + +
+ ))} +
+ + {savedCustomModelRows.length > 5 ? ( + + ) : null} +
+ ) : null} +
+
+
+ )} + + {activeSection === "mobile" && !isMobileShell && ( + + +
+ +
+
+
+ )} + + {activeSection === "advanced" && ( + Date: Sun, 12 Apr 2026 19:54:09 -0500 Subject: [PATCH 2/2] Improve provider status loading and settings availability - Scope provider health dependencies explicitly - Fetch server config earlier in chat view - Tidy provider selection and settings formatting --- .../src/provider/Layers/ProviderHealth.ts | 46 ++++++++++--------- apps/web/src/components/ChatView.tsx | 2 +- apps/web/src/lib/providerAvailability.ts | 5 +- apps/web/src/routes/_chat.settings.tsx | 9 ++-- 4 files changed, 31 insertions(+), 31 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 69f4e286d..19f5dd845 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -13,17 +13,7 @@ import type { ServerProviderStatus, ServerProviderStatusState, } from "@okcode/contracts"; -import { - Array, - Data, - Effect, - FileSystem, - Layer, - Option, - Path, - Result, - Stream, -} from "effect"; +import { Array, Data, Effect, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { @@ -681,16 +671,30 @@ const checkOpenClawProviderStatus: Effect.Effect 0 ? debouncedPathQuery : ""; const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); - const serverConfigQuery = useQuery(serverConfigQueryOptions()); const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ cwd: gitCwd, diff --git a/apps/web/src/lib/providerAvailability.ts b/apps/web/src/lib/providerAvailability.ts index e2eab8233..d25fa28e5 100644 --- a/apps/web/src/lib/providerAvailability.ts +++ b/apps/web/src/lib/providerAvailability.ts @@ -57,10 +57,7 @@ export function resolveThreadProviderSelection(input: { preferredProvider?: ProviderKind | null | undefined; selectableProviders: ReadonlyArray; }): ProviderKind { - if ( - input.preferredProvider && - input.selectableProviders.includes(input.preferredProvider) - ) { + if (input.preferredProvider && input.selectableProviders.includes(input.preferredProvider)) { return input.preferredProvider; } diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b70d2ebcd..a4cb5657d 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -367,8 +367,7 @@ const PROVIDER_AUTH_GUIDES: Record< installCmd: "npm install -g @openai/codex", authCmd: "codex login", verifyCmd: "codex login status", - note: - "Codex stays available in thread creation when the CLI is ready and its auth is either confirmed or delegated to a custom model provider.", + note: "Codex stays available in thread creation when the CLI is ready and its auth is either confirmed or delegated to a custom model provider.", }, claudeAgent: { installCmd: "npm install -g @anthropic-ai/claude-code", @@ -378,8 +377,7 @@ const PROVIDER_AUTH_GUIDES: Record< }, openclaw: { verifyCmd: "Test Connection", - note: - "OpenClaw uses the gateway URL and password below rather than a local CLI login. A configured gateway unlocks it for new-thread selection.", + note: "OpenClaw uses the gateway URL and password below rather than a local CLI login. A configured gateway unlocks it for new-thread selection.", }, }; @@ -2381,7 +2379,8 @@ function SettingsRouteView() { key={provider} provider={provider} status={ - providerStatuses.find((status) => status.provider === provider) ?? null + providerStatuses.find((status) => status.provider === provider) ?? + null } openclawGatewayUrl={settings.openclawGatewayUrl} />