feat: enable/disable providers from settings

Adds a per-provider toggle in Settings → Providers, persisted via daemon
config, so users can turn off providers they don't have installed (or
don't want to use) without removing them from the registry.

Disabled providers stay visible everywhere — settings, `paseo provider
ls`, MCP `list_providers`, model/mode lists — marked as disabled rather
than silently filtered. Availability probes are skipped for them, and
attempts to launch an agent or fetch models/modes from a disabled
provider return a clear "Provider '<id>' is disabled" error.

Schema additions (`enabled` on `ProviderSnapshotEntry`, MCP provider
summary) are backward-compatible: optional with a default of true so
older clients continue to parse new daemon messages.

CLI: `paseo provider ls` gains an ENABLED column; JSON output includes
the new field.
This commit is contained in:
Mohamed Boudra
2026-04-25 14:00:38 +07:00
parent de42858213
commit 4b3603604a
31 changed files with 1920 additions and 216 deletions
@@ -0,0 +1,163 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Switch } from "./switch";
const { theme } = vi.hoisted(() => ({
theme: {
opacity: { 50: 0.5 },
colors: {
surface3: "#333",
accent: "#0a84ff",
palette: { white: "#fff" },
},
},
}));
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
},
Easing: {
ease: "ease",
inOut: (value: unknown) => value,
},
interpolateColor: (value: number, _input: number[], output: string[]) =>
value >= 1 ? output[1] : output[0],
useAnimatedStyle: (factory: () => unknown) => factory(),
useDerivedValue: (factory: () => unknown) => ({ value: factory() }),
withTiming: (value: unknown) => value,
}));
vi.mock("react-native", () => ({
Pressable: ({
accessibilityLabel,
accessibilityRole,
accessibilityState,
children,
disabled,
onPress,
testID,
}: {
accessibilityLabel?: string;
accessibilityRole?: string;
accessibilityState?: { checked?: boolean; disabled?: boolean };
children: React.ReactNode;
disabled?: boolean;
onPress?: (event: { stopPropagation: () => void }) => void;
testID?: string;
}) =>
React.createElement(
"button",
{
"aria-checked": accessibilityState?.checked,
"aria-disabled": accessibilityState?.disabled,
"aria-label": accessibilityLabel,
"data-disabled": disabled,
"data-testid": testID,
onClick: () => onPress?.({ stopPropagation: vi.fn() }),
role: accessibilityRole,
type: "button",
},
children,
),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
describe("Switch", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container = null;
vi.unstubAllGlobals();
});
function renderSwitch(props: React.ComponentProps<typeof Switch>): HTMLElement {
act(() => {
root?.render(<Switch {...props} />);
});
const switchElement = container?.querySelector('[role="switch"]') as HTMLElement | null;
if (!switchElement) {
throw new Error("Expected switch element to render");
}
return switchElement;
}
function pressSwitch(switchElement: HTMLElement): void {
act(() => {
switchElement.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
it("renders with accessibility role switch", () => {
const switchElement = renderSwitch({
value: false,
accessibilityLabel: "Enable Claude",
testID: "provider-switch",
});
expect(switchElement.getAttribute("role")).toBe("switch");
expect(switchElement.getAttribute("aria-label")).toBe("Enable Claude");
});
it("calls onValueChange with the toggled value when pressed", () => {
const onValueChange = vi.fn();
const switchElement = renderSwitch({ value: false, onValueChange });
pressSwitch(switchElement);
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange).toHaveBeenCalledWith(true);
});
it("does not call onValueChange when disabled", () => {
const onValueChange = vi.fn();
const switchElement = renderSwitch({ value: false, onValueChange, disabled: true });
pressSwitch(switchElement);
expect(onValueChange).not.toHaveBeenCalled();
});
it("reflects checked accessibility state from value", () => {
const switchElement = renderSwitch({ value: true });
expect(switchElement.getAttribute("aria-checked")).toBe("true");
act(() => {
root?.render(<Switch value={false} />);
});
expect(switchElement.getAttribute("aria-checked")).toBe("false");
});
});
+133
View File
@@ -0,0 +1,133 @@
import { useCallback, useMemo } from "react";
import {
Pressable,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import Animated, {
Easing,
interpolateColor,
useAnimatedStyle,
useDerivedValue,
withTiming,
} from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
type SwitchSize = "sm" | "md";
interface SwitchProps {
value: boolean;
onValueChange?: (value: boolean) => void;
disabled?: boolean;
size?: SwitchSize;
accessibilityLabel?: string;
testID?: string;
style?: StyleProp<ViewStyle>;
}
const SIZES: Record<SwitchSize, { track: { width: number; height: number }; thumb: number }> = {
md: { track: { width: 44, height: 26 }, thumb: 22 },
sm: { track: { width: 34, height: 20 }, thumb: 16 },
};
const TIMING = { duration: 180, easing: Easing.inOut(Easing.ease) };
export function Switch({
value,
onValueChange,
disabled = false,
size = "md",
accessibilityLabel,
testID,
style,
}: SwitchProps) {
const { theme } = useUnistyles();
const { track, thumb } = SIZES[size];
const padding = (track.height - thumb) / 2;
const thumbTravel = track.width - thumb - padding * 2;
const progress = useDerivedValue(() => withTiming(value ? 1 : 0, TIMING));
const trackAnimatedStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
progress.value,
[0, 1],
[theme.colors.surface3, theme.colors.accent],
),
}));
const thumbAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: progress.value * thumbTravel }],
}));
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
if (disabled) return;
onValueChange?.(!value);
},
[disabled, onValueChange, value],
);
const accessibilityState = useMemo(() => ({ checked: value, disabled }), [value, disabled]);
const pressableStyle = useMemo(
() => [disabled ? styles.disabled : null, style],
[disabled, style],
);
const trackStyle = useMemo(
() => [
styles.track,
{
width: track.width,
height: track.height,
borderRadius: track.height / 2,
padding,
},
trackAnimatedStyle,
],
[track.width, track.height, padding, trackAnimatedStyle],
);
const thumbStyle = useMemo(
() => [
styles.thumb,
{ width: thumb, height: thumb, borderRadius: thumb / 2 },
thumbAnimatedStyle,
],
[thumb, thumbAnimatedStyle],
);
return (
<Pressable
onPress={handlePress}
disabled={disabled}
hitSlop={8}
accessibilityRole="switch"
accessibilityState={accessibilityState}
accessibilityLabel={accessibilityLabel}
testID={testID}
style={pressableStyle}
>
<Animated.View style={trackStyle}>
<Animated.View style={thumbStyle} />
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
track: {
justifyContent: "center",
},
thumb: {
backgroundColor: theme.colors.palette.white,
shadowColor: "rgba(0, 0, 0, 0.25)",
shadowOffset: { width: 0, height: 1 },
shadowRadius: 2,
shadowOpacity: 1,
elevation: 2,
},
disabled: {
opacity: theme.opacity[50],
},
}));
@@ -78,6 +78,7 @@ describe("useAgentFormState live preference hydration", () => {
{
provider: "codex",
status: "ready",
enabled: true,
label: "Codex",
description: "Codex test provider",
defaultModeId: "auto",
@@ -107,6 +108,7 @@ describe("useAgentFormState live preference hydration", () => {
{
provider: "claude",
status: "ready",
enabled: true,
label: "Claude",
description: "Claude test provider",
defaultModeId: "default",
@@ -53,6 +53,7 @@ describe("useAgentFormState", () => {
{
provider: "zai",
status: "ready",
enabled: true,
label: "ZAI",
description: "Claude with ZAI config",
defaultModeId: "default",
@@ -69,6 +70,7 @@ describe("useAgentFormState", () => {
{
provider: "claude",
status: "ready",
enabled: true,
label: "Claude",
description: "Anthropic Claude",
defaultModeId: "default",
@@ -544,6 +546,7 @@ describe("useAgentFormState", () => {
{
provider: "codex",
status: "loading",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
@@ -552,6 +555,7 @@ describe("useAgentFormState", () => {
{
provider: "claude",
status: "ready",
enabled: true,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
@@ -596,11 +600,12 @@ describe("useAgentFormState", () => {
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
const unavailableEntries: ProviderSnapshotEntry[] = [
it("ignores disabled ready providers when resolving selectable defaults", () => {
const entries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "unavailable",
status: "ready",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
@@ -609,6 +614,106 @@ describe("useAgentFormState", () => {
{
provider: "claude",
status: "ready",
enabled: false,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
},
];
const providerDefinitions = buildProviderDefinitions(entries);
const selectableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
snapshotEntries: entries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
});
const resolved = __private__.resolveFormState(
undefined,
{ provider: "claude" },
null,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
selectableProviderMap,
);
expect(resolved.provider).toBe("codex");
expect(resolved.modeId).toBe("auto");
});
it("excludes disabled providers from the selectable provider map without removing them from snapshot definitions", () => {
const entries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "ready",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
enabled: false,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
},
];
const providerDefinitions = buildProviderDefinitions(entries);
const selectableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
snapshotEntries: entries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
});
const providerDefinitionIds = [providerDefinitions[0]?.id, providerDefinitions[1]?.id];
const snapshotProviderStates = [
{ provider: entries[0]?.provider, enabled: entries[0]?.enabled },
{ provider: entries[1]?.provider, enabled: entries[1]?.enabled },
];
expect([...selectableProviderMap.keys()]).toEqual(["codex"]);
expect(providerDefinitionIds).toEqual(["codex", "claude"]);
expect(snapshotProviderStates).toEqual([
{ provider: "codex", enabled: true },
{ provider: "claude", enabled: false },
]);
});
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
const unavailableEntries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "unavailable",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
enabled: true,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
@@ -396,7 +396,7 @@ function buildProviderDefinitionMapForStatuses(args: {
const matchingProviders = new Set(
args.snapshotEntries
.filter((entry) => args.statuses.has(entry.status))
.filter((entry) => args.statuses.has(entry.status) && entry.enabled !== false)
.map((entry) => entry.provider),
);
@@ -101,6 +101,7 @@ function codexEntry(
return {
provider: "codex",
status,
enabled: true,
...(models ? { models } : {}),
};
}
@@ -0,0 +1,347 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import type { MutableDaemonConfig } from "@server/shared/messages";
const { theme, snapshotState, configState, patchConfigMock, refreshMock } = vi.hoisted(() => ({
theme: {
spacing: { 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24 },
iconSize: { sm: 14, md: 20 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" },
borderRadius: { lg: 8 },
opacity: { 50: 0.5 },
colors: {
surface1: "#111",
surface2: "#222",
surface3: "#333",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
accent: "#0a84ff",
statusSuccess: "#00ff00",
statusWarning: "#ff9500",
statusDanger: "#ff0000",
palette: { red: { 300: "#ff6b6b" }, white: "#fff" },
},
},
snapshotState: {
entries: undefined as ProviderSnapshotEntry[] | undefined,
isLoading: false,
isRefreshing: false,
},
configState: {
config: null as MutableDaemonConfig | null,
},
patchConfigMock: vi.fn(async () => undefined),
refreshMock: vi.fn(async () => {}),
}));
vi.mock("react-native", () => ({
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
React.createElement("div", { "data-testid": testID }, children),
Text: ({ children }: { children?: React.ReactNode }) =>
React.createElement("span", null, children),
Pressable: ({
children,
onPress,
onHoverIn,
onHoverOut,
accessibilityRole,
accessibilityLabel,
disabled,
testID,
}: {
children?:
| React.ReactNode
| ((state: { pressed: boolean; hovered: boolean }) => React.ReactNode);
onPress?: (event: React.MouseEvent) => void;
onHoverIn?: () => void;
onHoverOut?: () => void;
accessibilityRole?: string;
accessibilityLabel?: string;
disabled?: boolean;
testID?: string;
}) =>
React.createElement(
"div",
{
role: accessibilityRole,
"aria-label": accessibilityLabel,
"aria-disabled": disabled ? "true" : undefined,
"data-testid": testID,
onClick: disabled ? undefined : onPress,
onMouseEnter: onHoverIn,
onMouseLeave: onHoverOut,
},
typeof children === "function" ? children({ pressed: false, hovered: false }) : children,
),
ActivityIndicator: () => React.createElement("span", { "data-testid": "activity-indicator" }),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
return {
ChevronRight: icon("ChevronRight"),
RotateCw: icon("RotateCw"),
};
});
vi.mock("@/components/ui/switch", () => ({
Switch: ({
value,
onValueChange,
disabled,
accessibilityLabel,
testID,
}: {
value: boolean;
onValueChange?: (next: boolean) => void;
disabled?: boolean;
accessibilityLabel?: string;
testID?: string;
}) =>
React.createElement("div", {
role: "switch",
"aria-checked": value ? "true" : "false",
"aria-disabled": disabled ? "true" : undefined,
"aria-label": accessibilityLabel,
"data-testid": testID ?? "provider-switch",
onClick: (event: React.MouseEvent) => {
event.stopPropagation();
if (disabled) return;
onValueChange?.(!value);
},
}),
}));
vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: () => React.createElement("span", { "data-testid": "loading-spinner" }),
}));
vi.mock("@/components/provider-icons", () => ({
getProviderIcon: (provider: string) => () =>
React.createElement("span", { "data-icon": `provider-${provider}` }),
}));
vi.mock("@/components/provider-diagnostic-sheet", () => ({
ProviderDiagnosticSheet: ({ provider }: { provider: string }) =>
React.createElement("div", {
"data-testid": "provider-diagnostic-sheet",
"data-provider": provider,
}),
}));
vi.mock("@/hooks/use-providers-snapshot", () => ({
useProvidersSnapshot: () => ({
entries: snapshotState.entries,
isLoading: snapshotState.isLoading,
isFetching: false,
isRefreshing: snapshotState.isRefreshing,
error: null,
supportsSnapshot: true,
refresh: refreshMock,
refetchIfStale: vi.fn(),
}),
}));
vi.mock("@/hooks/use-daemon-config", () => ({
useDaemonConfig: () => ({
config: configState.config,
isLoading: false,
patchConfig: patchConfigMock,
}),
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeIsConnected: () => true,
}));
import { ProvidersSection } from "./providers-section";
const claudeEntry: ProviderSnapshotEntry = {
provider: "claude",
status: "ready",
enabled: true,
label: "Claude",
description: "Claude Code",
defaultModeId: null,
modes: [],
models: [
{ provider: "claude", id: "claude-opus-4-7", label: "Claude Opus 4.7" },
{ provider: "claude", id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ provider: "claude", id: "claude-haiku-4-5", label: "Claude Haiku 4.5" },
],
};
const disabledCodexEntry: ProviderSnapshotEntry = {
provider: "codex",
status: "unavailable",
enabled: false,
label: "Codex",
description: "OpenAI Codex",
defaultModeId: null,
modes: [],
};
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
return { mcp: { injectIntoAgents: false }, providers };
}
function descendants(el: HTMLElement): HTMLElement[] {
return Array.from(el.querySelectorAll<HTMLElement>("*"));
}
function indexOfMatches(nodes: HTMLElement[], selector: string): number {
return nodes.findIndex((node) => node.matches(selector));
}
function indexOfText(nodes: HTMLElement[], text: string): number {
return nodes.findIndex((node) => node.textContent?.trim() === text);
}
describe("ProvidersSection", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
snapshotState.entries = undefined;
snapshotState.isLoading = false;
snapshotState.isRefreshing = false;
configState.config = null;
patchConfigMock.mockReset();
patchConfigMock.mockResolvedValue(undefined);
refreshMock.mockReset();
refreshMock.mockResolvedValue(undefined);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
vi.unstubAllGlobals();
});
function render(): void {
act(() => {
root?.render(<ProvidersSection serverId="server-1" />);
});
}
function findRow(accessibilityLabel: string): HTMLElement {
const row = container?.querySelector<HTMLElement>(
`[role="button"][aria-label="${accessibilityLabel}"]`,
);
if (!row) throw new Error(`Expected row with aria-label "${accessibilityLabel}"`);
return row;
}
it("renders the disabled provider with its server-provided label in snapshot order", () => {
snapshotState.entries = [claudeEntry, disabledCodexEntry];
configState.config = makeConfig({ codex: { enabled: false } });
render();
const rows = Array.from(
container?.querySelectorAll<HTMLElement>('[role="button"][aria-label$="provider details"]') ??
[],
);
expect(rows.map((row) => row.getAttribute("aria-label"))).toEqual([
"Claude provider details",
"Codex provider details",
]);
const codexRow = findRow("Codex provider details");
const codexNodes = descendants(codexRow);
expect(indexOfText(codexNodes, "Codex")).toBeGreaterThanOrEqual(0);
expect(indexOfText(codexNodes, "codex")).toBe(-1);
expect(indexOfText(codexNodes, "Disabled")).toBeGreaterThanOrEqual(0);
});
it("composes the row as chevron, icon, label, status, model count, then switch", () => {
snapshotState.entries = [claudeEntry];
configState.config = makeConfig();
render();
const row = findRow("Claude provider details");
const nodes = descendants(row);
const chevron = indexOfMatches(nodes, '[data-icon="ChevronRight"]');
const icon = indexOfMatches(nodes, '[data-icon="provider-claude"]');
const label = indexOfText(nodes, "Claude");
const status = indexOfText(nodes, "Available");
const modelCount = indexOfText(nodes, "3 models");
const switchEl = indexOfMatches(nodes, '[role="switch"]');
expect(chevron).toBeGreaterThanOrEqual(0);
expect(icon).toBeGreaterThan(chevron);
expect(label).toBeGreaterThan(icon);
expect(status).toBeGreaterThan(label);
expect(modelCount).toBeGreaterThan(status);
expect(switchEl).toBeGreaterThan(modelCount);
});
it("opens the diagnostic sheet when the outer row is pressed for a disabled provider", () => {
snapshotState.entries = [disabledCodexEntry];
configState.config = makeConfig({ codex: { enabled: false } });
render();
expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull();
const row = findRow("Codex provider details");
act(() => {
row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
const sheet = container?.querySelector('[data-testid="provider-diagnostic-sheet"]');
expect(sheet).not.toBeNull();
expect(sheet?.getAttribute("data-provider")).toBe("codex");
});
it("toggles the provider enabled flag through patchConfig when the switch is pressed", () => {
snapshotState.entries = [claudeEntry];
configState.config = makeConfig();
render();
const row = findRow("Claude provider details");
const switchEl = row.querySelector<HTMLElement>('[role="switch"]');
expect(switchEl).not.toBeNull();
expect(switchEl?.getAttribute("aria-checked")).toBe("true");
act(() => {
switchEl?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
expect(patchConfigMock).toHaveBeenCalledTimes(1);
expect(patchConfigMock).toHaveBeenCalledWith({
providers: { claude: { enabled: false } },
});
expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull();
});
});
@@ -1,83 +1,177 @@
import { useCallback, useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { Alert, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import { getProviderIcon } from "@/components/provider-icons";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { StatusBadge } from "@/components/ui/status-badge";
import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/screens/settings/settings-section";
import { RotateCw } from "lucide-react-native";
import { ChevronRight, RotateCw } from "lucide-react-native";
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
function getProviderStatusLabel(status: string): string {
if (status === "ready") return "Available";
if (status === "error") return "Error";
if (status === "loading") return "Loading...";
return "Not installed";
type StatusTone = "success" | "warning" | "danger" | "muted" | "loading";
interface ProviderStatus {
tone: StatusTone;
label: string;
modelCount: number | null;
}
function getProviderStatusVariant(status: string): "success" | "error" | "muted" {
if (status === "ready") return "success";
if (status === "error") return "error";
return "muted";
function getProviderStatus(status: string, enabled: boolean, modelCount: number): ProviderStatus {
if (!enabled) return { tone: "muted", label: "Disabled", modelCount: null };
if (status === "loading") return { tone: "loading", label: "Loading", modelCount: null };
if (status === "error") return { tone: "danger", label: "Error", modelCount: null };
if (status === "ready") {
return {
tone: "success",
label: "Available",
modelCount: modelCount > 0 ? modelCount : null,
};
}
return { tone: "warning", label: "Not installed", modelCount: null };
}
interface ProviderRowProps {
def: ProviderDefinition;
entry: ProviderEntry | undefined;
entry: ProviderEntry;
enabled: boolean;
isToggling: boolean;
isFirst: boolean;
onPress: (providerId: string) => void;
onToggleEnabled: (providerId: string, enabled: boolean) => void;
}
function ProviderRow({ def, entry, isFirst, onPress }: ProviderRowProps) {
function ProviderRow({
def,
entry,
enabled,
isToggling,
isFirst,
onPress,
onToggleEnabled,
}: ProviderRowProps) {
const { theme } = useUnistyles();
const status = entry?.status ?? "unavailable";
const ProviderIcon = getProviderIcon(def.id);
const providerError =
status === "error" && typeof entry?.error === "string" && entry.error.trim().length > 0
enabled &&
entry.status === "error" &&
typeof entry.error === "string" &&
entry.error.trim().length > 0
? entry.error.trim()
: null;
const modelCount = entry?.models?.length ?? 0;
const modelCount = entry.models?.length ?? 0;
const providerStatus = getProviderStatus(entry.status, enabled, modelCount);
const handlePress = useCallback(() => onPress(def.id), [def.id, onPress]);
const rowStyle = useMemo(
() => [settingsStyles.row, !isFirst && settingsStyles.rowBorder],
const handlePress = useCallback(() => {
onPress(def.id);
}, [def.id, onPress]);
const handleToggleValueChange = useCallback(
(value: boolean) => {
onToggleEnabled(def.id, value);
},
[def.id, onToggleEnabled],
);
const rowStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
settingsStyles.row,
!isFirst && settingsStyles.rowBorder,
styles.row,
hovered && styles.rowHovered,
pressed && styles.rowPressed,
],
[isFirst],
);
return (
<Pressable style={rowStyle} onPress={handlePress} accessibilityRole="button">
<View style={settingsStyles.rowContent}>
<View style={styles.titleRow}>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>{def.label}</Text>
</View>
{providerError ? (
<Text style={styles.errorText} numberOfLines={3}>
{providerError}
</Text>
) : null}
{status === "ready" && modelCount > 0 ? (
<Text style={settingsStyles.rowHint}>
{modelCount === 1 ? "1 model" : `${modelCount} models`}
</Text>
) : null}
</View>
<StatusBadge
label={getProviderStatusLabel(status)}
variant={getProviderStatusVariant(status)}
/>
<Pressable
style={rowStyle}
onPress={handlePress}
accessibilityRole="button"
accessibilityLabel={`${def.label} provider details`}
>
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
<>
<View style={styles.rowContent}>
<ChevronRight
size={theme.iconSize.sm}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foreground} />
<View style={styles.textColumn}>
<View style={styles.titleRow}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{def.label}
</Text>
<Text style={styles.separator}>·</Text>
<StatusIndicator status={providerStatus} />
</View>
{providerError ? (
<Text style={styles.errorText} numberOfLines={3}>
{providerError}
</Text>
) : null}
</View>
</View>
<Switch
value={enabled}
onValueChange={handleToggleValueChange}
disabled={isToggling}
size="sm"
accessibilityLabel={`Enable ${def.label}`}
/>
</>
)}
</Pressable>
);
}
function getDotColor(tone: StatusTone, theme: ReturnType<typeof useUnistyles>["theme"]): string {
switch (tone) {
case "success":
return theme.colors.statusSuccess;
case "warning":
return theme.colors.statusWarning;
case "danger":
return theme.colors.statusDanger;
default:
return theme.colors.foregroundMuted;
}
}
function StatusIndicator({ status }: { status: ProviderStatus }) {
const { theme } = useUnistyles();
const dotStyle = useMemo(
() => [styles.statusDot, { backgroundColor: getDotColor(status.tone, theme) }],
[status.tone, theme],
);
return (
<View style={styles.statusRow}>
{status.tone === "loading" ? (
<LoadingSpinner size={10} color={theme.colors.foregroundMuted} />
) : (
<View style={dotStyle} />
)}
<Text style={styles.statusLabel}>{status.label}</Text>
{status.modelCount !== null ? (
<>
<Text style={styles.separator}>·</Text>
<Text style={styles.statusLabel}>
{status.modelCount === 1 ? "1 model" : `${status.modelCount} models`}
</Text>
</>
) : null}
</View>
);
}
export interface ProvidersSectionProps {
serverId: string;
}
@@ -86,8 +180,11 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
const { theme } = useUnistyles();
const isConnected = useHostRuntimeIsConnected(serverId);
const { entries, isLoading, isRefreshing, refresh } = useProvidersSnapshot(serverId);
const { patchConfig } = useDaemonConfig(serverId);
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
const providerDefinitions = buildProviderDefinitions(entries);
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
const providerRefreshInFlight =
isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false);
const hasServer = serverId.length > 0;
@@ -97,6 +194,22 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
}, [refresh]);
const handleCloseDiagnostic = useCallback(() => setDiagnosticProvider(null), []);
const handleToggleEnabled = useCallback(
async (providerId: string, enabled: boolean) => {
setPendingProviderId(providerId);
try {
await patchConfig({ providers: { [providerId]: { enabled } } });
} catch (error) {
Alert.alert(
"Unable to update provider",
error instanceof Error ? error.message : String(error),
);
} finally {
setPendingProviderId((current) => (current === providerId ? null : current));
}
},
[patchConfig],
);
const refreshAction = useMemo(
() =>
@@ -146,17 +259,24 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
<Text style={styles.emptyText}>Loading...</Text>
</View>
) : null}
{hasServer && isConnected && !isLoading ? (
{hasServer && isConnected && !isLoading && providerDefinitions.length > 0 ? (
<View style={settingsStyles.card}>
{providerDefinitions.map((def, index) => (
<ProviderRow
key={def.id}
def={def}
entry={entries?.find((e) => e.provider === def.id)}
isFirst={index === 0}
onPress={setDiagnosticProvider}
/>
))}
{providerDefinitions.map((def, index) => {
const entry = entries?.find((candidate) => candidate.provider === def.id);
if (!entry) return null;
return (
<ProviderRow
key={def.id}
def={def}
entry={entry}
enabled={entry.enabled ?? true}
isToggling={pendingProviderId === def.id}
isFirst={index === 0}
onPress={setDiagnosticProvider}
onToggleEnabled={handleToggleEnabled}
/>
);
})}
</View>
) : null}
</SettingsSection>
@@ -185,11 +305,49 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
row: {
gap: theme.spacing[3],
minHeight: 56,
},
rowHovered: {
backgroundColor: theme.colors.surface2,
},
rowPressed: {
backgroundColor: theme.colors.surface3,
},
rowContent: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
textColumn: {
flex: 1,
minWidth: 0,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
statusRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
statusLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
separator: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
errorText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,