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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user