mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
feat(providers): remove custom providers from settings
Add a destructive removal flow so mistaken custom providers can be deleted from config.json instead of only disabled.
This commit is contained in:
@@ -1909,6 +1909,17 @@ export const ar: TranslationResources = {
|
||||
loading: "تحميل...",
|
||||
addErrorTitle: "Unable to add provider",
|
||||
updateErrorTitle: "غير قادر على تحديث الموفر",
|
||||
actions: {
|
||||
menu: "{{name}} actions",
|
||||
remove: "Remove provider",
|
||||
removing: "Removing...",
|
||||
},
|
||||
remove: {
|
||||
confirmTitle: "Remove {{name}}?",
|
||||
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
|
||||
confirm: "Remove",
|
||||
errorTitle: "Unable to remove provider",
|
||||
},
|
||||
statuses: {
|
||||
disabled: "عاجز",
|
||||
loading: "تحميل",
|
||||
|
||||
@@ -1918,6 +1918,17 @@ export const en = {
|
||||
loading: "Loading...",
|
||||
addErrorTitle: "Unable to add provider",
|
||||
updateErrorTitle: "Unable to update provider",
|
||||
actions: {
|
||||
menu: "{{name}} actions",
|
||||
remove: "Remove provider",
|
||||
removing: "Removing...",
|
||||
},
|
||||
remove: {
|
||||
confirmTitle: "Remove {{name}}?",
|
||||
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
|
||||
confirm: "Remove",
|
||||
errorTitle: "Unable to remove provider",
|
||||
},
|
||||
statuses: {
|
||||
disabled: "Disabled",
|
||||
loading: "Loading",
|
||||
|
||||
@@ -1956,6 +1956,17 @@ export const es: TranslationResources = {
|
||||
loading: "Cargando...",
|
||||
addErrorTitle: "Unable to add provider",
|
||||
updateErrorTitle: "No se puede actualizar el proveedor",
|
||||
actions: {
|
||||
menu: "{{name}} actions",
|
||||
remove: "Remove provider",
|
||||
removing: "Removing...",
|
||||
},
|
||||
remove: {
|
||||
confirmTitle: "Remove {{name}}?",
|
||||
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
|
||||
confirm: "Remove",
|
||||
errorTitle: "Unable to remove provider",
|
||||
},
|
||||
statuses: {
|
||||
disabled: "Desactivado",
|
||||
loading: "Cargando",
|
||||
|
||||
@@ -1959,6 +1959,17 @@ export const fr: TranslationResources = {
|
||||
loading: "Chargement...",
|
||||
addErrorTitle: "Unable to add provider",
|
||||
updateErrorTitle: "Impossible de mettre à jour le fournisseur",
|
||||
actions: {
|
||||
menu: "{{name}} actions",
|
||||
remove: "Remove provider",
|
||||
removing: "Removing...",
|
||||
},
|
||||
remove: {
|
||||
confirmTitle: "Remove {{name}}?",
|
||||
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
|
||||
confirm: "Remove",
|
||||
errorTitle: "Unable to remove provider",
|
||||
},
|
||||
statuses: {
|
||||
disabled: "Désactivé",
|
||||
loading: "Chargement",
|
||||
|
||||
@@ -1931,6 +1931,17 @@ export const ja: TranslationResources = {
|
||||
loading: "読み込み中...",
|
||||
addErrorTitle: "プロバイダーを追加できません",
|
||||
updateErrorTitle: "プロバイダーを更新できません",
|
||||
actions: {
|
||||
menu: "{{name}} actions",
|
||||
remove: "Remove provider",
|
||||
removing: "Removing...",
|
||||
},
|
||||
remove: {
|
||||
confirmTitle: "Remove {{name}}?",
|
||||
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
|
||||
confirm: "Remove",
|
||||
errorTitle: "Unable to remove provider",
|
||||
},
|
||||
statuses: {
|
||||
disabled: "無効",
|
||||
loading: "読み込み中",
|
||||
|
||||
@@ -1941,6 +1941,17 @@ export const ptBR: TranslationResources = {
|
||||
loading: "Carregando...",
|
||||
addErrorTitle: "Não foi possível adicionar provedor",
|
||||
updateErrorTitle: "Não foi possível atualizar provedor",
|
||||
actions: {
|
||||
menu: "{{name}} actions",
|
||||
remove: "Remove provider",
|
||||
removing: "Removing...",
|
||||
},
|
||||
remove: {
|
||||
confirmTitle: "Remove {{name}}?",
|
||||
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
|
||||
confirm: "Remove",
|
||||
errorTitle: "Unable to remove provider",
|
||||
},
|
||||
statuses: {
|
||||
disabled: "Desativado",
|
||||
loading: "Carregando",
|
||||
|
||||
@@ -1946,6 +1946,17 @@ export const ru: TranslationResources = {
|
||||
loading: "Загрузка...",
|
||||
addErrorTitle: "Unable to add provider",
|
||||
updateErrorTitle: "Невозможно обновить провайдера",
|
||||
actions: {
|
||||
menu: "{{name}} actions",
|
||||
remove: "Remove provider",
|
||||
removing: "Removing...",
|
||||
},
|
||||
remove: {
|
||||
confirmTitle: "Remove {{name}}?",
|
||||
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
|
||||
confirm: "Remove",
|
||||
errorTitle: "Unable to remove provider",
|
||||
},
|
||||
statuses: {
|
||||
disabled: "Неполноценный",
|
||||
loading: "Загрузка",
|
||||
|
||||
@@ -1886,6 +1886,17 @@ export const zhCN: TranslationResources = {
|
||||
loading: "正在加载...",
|
||||
addErrorTitle: "无法添加 Provider",
|
||||
updateErrorTitle: "无法更新 Provider",
|
||||
actions: {
|
||||
menu: "{{name}} actions",
|
||||
remove: "Remove provider",
|
||||
removing: "Removing...",
|
||||
},
|
||||
remove: {
|
||||
confirmTitle: "Remove {{name}}?",
|
||||
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
|
||||
confirm: "Remove",
|
||||
errorTitle: "Unable to remove provider",
|
||||
},
|
||||
statuses: {
|
||||
disabled: "已禁用",
|
||||
loading: "正在加载",
|
||||
|
||||
@@ -7,41 +7,51 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
|
||||
|
||||
const { theme, snapshotState, configState, patchConfigMock, openProviderSettingsMock } = 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" },
|
||||
},
|
||||
const {
|
||||
theme,
|
||||
snapshotState,
|
||||
configState,
|
||||
hostFeatureState,
|
||||
patchConfigMock,
|
||||
openProviderSettingsMock,
|
||||
confirmDialogMock,
|
||||
} = 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),
|
||||
openProviderSettingsMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
snapshotState: {
|
||||
entries: undefined as ProviderSnapshotEntry[] | undefined,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
},
|
||||
configState: {
|
||||
config: null as MutableDaemonConfig | null,
|
||||
},
|
||||
hostFeatureState: {
|
||||
providerRemoval: false,
|
||||
},
|
||||
patchConfigMock: vi.fn(async () => undefined),
|
||||
openProviderSettingsMock: vi.fn(),
|
||||
confirmDialogMock: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("react-native", () => ({
|
||||
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
|
||||
@@ -97,25 +107,39 @@ vi.mock("lucide-react-native", () => {
|
||||
const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
|
||||
return {
|
||||
ChevronRight: icon("ChevronRight"),
|
||||
MoreHorizontal: icon("MoreHorizontal"),
|
||||
Trash2: icon("Trash2"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, string | number>) => {
|
||||
if (key === "settings.providers.providerDetails") return `${values?.name} provider details`;
|
||||
if (key === "settings.providers.enableProvider") return `Enable ${values?.name}`;
|
||||
if (key === "settings.providers.statuses.disabled") return "Disabled";
|
||||
if (key === "settings.providers.statuses.available") return "Available";
|
||||
if (key === "settings.providers.statuses.loading") return "Loading";
|
||||
if (key === "settings.providers.statuses.error") return "Error";
|
||||
if (key === "settings.providers.statuses.notInstalled") return "Not installed";
|
||||
if (key === "settings.providers.models.one") return "1 model";
|
||||
if (key === "settings.providers.models.many") return `${values?.count} models`;
|
||||
if (key === "settings.providers.addErrorTitle") return "Unable to add provider";
|
||||
if (key === "settings.providers.updateErrorTitle") return "Unable to update provider";
|
||||
return key;
|
||||
},
|
||||
t: (key: string, values?: Record<string, string | number>) =>
|
||||
(
|
||||
({
|
||||
"settings.providers.providerDetails": "{{name}} provider details",
|
||||
"settings.providers.enableProvider": "Enable {{name}}",
|
||||
"settings.providers.statuses.disabled": "Disabled",
|
||||
"settings.providers.statuses.available": "Available",
|
||||
"settings.providers.statuses.loading": "Loading",
|
||||
"settings.providers.statuses.error": "Error",
|
||||
"settings.providers.statuses.notInstalled": "Not installed",
|
||||
"settings.providers.models.one": "1 model",
|
||||
"settings.providers.models.many": "{{count}} models",
|
||||
"settings.providers.addErrorTitle": "Unable to add provider",
|
||||
"settings.providers.updateErrorTitle": "Unable to update provider",
|
||||
"settings.providers.actions.menu": "{{name}} actions",
|
||||
"settings.providers.actions.remove": "Remove provider",
|
||||
"settings.providers.actions.removing": "Removing...",
|
||||
"settings.providers.remove.confirmTitle": "Remove {{name}}?",
|
||||
"settings.providers.remove.confirmMessage":
|
||||
"This deletes the provider entry from config.json. It cannot be undone.",
|
||||
"settings.providers.remove.confirm": "Remove",
|
||||
"settings.providers.remove.errorTitle": "Unable to remove provider",
|
||||
})[key] ?? key
|
||||
)
|
||||
.replaceAll("{{name}}", String(values?.name ?? ""))
|
||||
.replaceAll("{{count}}", String(values?.count ?? "")),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -151,6 +175,68 @@ vi.mock("@/components/ui/loading-spinner", () => ({
|
||||
LoadingSpinner: () => React.createElement("span", { "data-testid": "loading-spinner" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", null, children),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
onPressIn,
|
||||
accessibilityRole,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
}: {
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((state: { pressed: boolean; hovered: boolean; open: boolean }) => React.ReactNode);
|
||||
onPressIn?: (event: { stopPropagation: () => void }) => void;
|
||||
accessibilityRole?: string;
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
role: accessibilityRole,
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-testid": testID,
|
||||
onMouseDown: (event: React.MouseEvent) => onPressIn?.(event),
|
||||
onClick: (event: React.MouseEvent) => event.stopPropagation(),
|
||||
},
|
||||
typeof children === "function"
|
||||
? children({ pressed: false, hovered: false, open: false })
|
||||
: children,
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", null, children),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
status,
|
||||
pendingLabel,
|
||||
testID,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
status?: "idle" | "pending" | "success";
|
||||
pendingLabel?: string;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
"data-testid": testID,
|
||||
disabled: status === "pending" || status === "success",
|
||||
onClick: (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.();
|
||||
},
|
||||
},
|
||||
status === "pending" ? pendingLabel : children,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-icons", () => ({
|
||||
getProviderIcon: (provider: string) => () =>
|
||||
React.createElement("span", { "data-icon": `provider-${provider}` }),
|
||||
@@ -190,6 +276,17 @@ vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeIsConnected: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-features", () => ({
|
||||
useHostFeature: (serverId: string, feature: string) =>
|
||||
serverId === "server-1" && feature === "providerRemoval"
|
||||
? hostFeatureState.providerRemoval
|
||||
: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/confirm-dialog", () => ({
|
||||
confirmDialog: confirmDialogMock,
|
||||
}));
|
||||
|
||||
import { ProvidersSection } from "./providers-section";
|
||||
|
||||
const claudeEntry: ProviderSnapshotEntry = {
|
||||
@@ -217,6 +314,18 @@ const disabledCodexEntry: ProviderSnapshotEntry = {
|
||||
modes: [],
|
||||
};
|
||||
|
||||
const customGeminiEntry: ProviderSnapshotEntry = {
|
||||
provider: "gemini",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
source: "custom",
|
||||
label: "Gemini",
|
||||
description: "Gemini CLI",
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
models: [{ provider: "gemini", id: "gemini-3-pro", label: "Gemini 3 Pro" }],
|
||||
};
|
||||
|
||||
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
|
||||
return {
|
||||
mcp: { injectIntoAgents: false },
|
||||
@@ -257,9 +366,12 @@ describe("ProvidersSection", () => {
|
||||
snapshotState.isLoading = false;
|
||||
snapshotState.isRefreshing = false;
|
||||
configState.config = null;
|
||||
hostFeatureState.providerRemoval = false;
|
||||
patchConfigMock.mockReset();
|
||||
patchConfigMock.mockResolvedValue(undefined);
|
||||
openProviderSettingsMock.mockReset();
|
||||
confirmDialogMock.mockReset();
|
||||
confirmDialogMock.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -373,4 +485,41 @@ describe("ProvidersSection", () => {
|
||||
providers: { claude: { enabled: false } },
|
||||
});
|
||||
});
|
||||
|
||||
it("removes a custom provider through the actions menu after confirmation", async () => {
|
||||
snapshotState.entries = [customGeminiEntry];
|
||||
configState.config = makeConfig({ gemini: {} });
|
||||
hostFeatureState.providerRemoval = true;
|
||||
|
||||
render();
|
||||
|
||||
const row = findRow("Gemini provider details");
|
||||
const menu = row.querySelector<HTMLElement>('[data-testid="provider-actions-gemini"]');
|
||||
const remove = row.querySelector<HTMLElement>('[data-testid="provider-remove-gemini"]');
|
||||
expect(menu?.getAttribute("aria-label")).toBe("Gemini actions");
|
||||
expect(remove?.textContent).toBe("Remove provider");
|
||||
|
||||
await act(async () => {
|
||||
remove?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(confirmDialogMock).toHaveBeenCalledWith({
|
||||
title: "Remove Gemini?",
|
||||
message: "This deletes the provider entry from config.json. It cannot be undone.",
|
||||
confirmLabel: "Remove",
|
||||
destructive: true,
|
||||
});
|
||||
expect(patchConfigMock).toHaveBeenCalledWith({ removeProviders: ["gemini"] });
|
||||
});
|
||||
|
||||
it("does not show provider removal for built-in providers", () => {
|
||||
snapshotState.entries = [claudeEntry];
|
||||
configState.config = makeConfig();
|
||||
hostFeatureState.providerRemoval = true;
|
||||
|
||||
render();
|
||||
|
||||
const row = findRow("Claude provider details");
|
||||
expect(row.querySelector('[data-testid="provider-actions-claude"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
@@ -16,9 +24,16 @@ import { ProviderCatalogList } from "@/components/provider-catalog-list";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { ChevronRight, MoreHorizontal, Trash2 } from "lucide-react-native";
|
||||
|
||||
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
|
||||
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
|
||||
@@ -64,9 +79,84 @@ interface ProviderRowProps {
|
||||
entry: ProviderEntry;
|
||||
enabled: boolean;
|
||||
isToggling: boolean;
|
||||
isRemoving: boolean;
|
||||
canRemove: boolean;
|
||||
isFirst: boolean;
|
||||
onPress: (providerId: string) => void;
|
||||
onToggleEnabled: (providerId: string, enabled: boolean) => void;
|
||||
onRemove: (providerId: string, providerLabel: string) => void;
|
||||
}
|
||||
|
||||
function stopPressInPropagation(event: GestureResponderEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
interface ProviderActionsMenuProps {
|
||||
providerId: string;
|
||||
providerLabel: string;
|
||||
isRemoving: boolean;
|
||||
onRemove: (providerId: string, providerLabel: string) => void;
|
||||
}
|
||||
|
||||
function ProviderActionsMenu({
|
||||
providerId,
|
||||
providerLabel,
|
||||
isRemoving,
|
||||
onRemove,
|
||||
}: ProviderActionsMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const handleRemove = useCallback(() => {
|
||||
onRemove(providerId, providerLabel);
|
||||
}, [onRemove, providerId, providerLabel]);
|
||||
const triggerStyle = useCallback(
|
||||
({
|
||||
pressed,
|
||||
hovered,
|
||||
open,
|
||||
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) => [
|
||||
styles.menuButton,
|
||||
(hovered || open) && styles.menuButtonHovered,
|
||||
pressed && styles.menuButtonPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
const trashLeading = useMemo(
|
||||
() => <Trash2 size={16} color={theme.colors.statusDanger} />,
|
||||
[theme.colors.statusDanger],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
onPressIn={stopPressInPropagation}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.providers.actions.menu", { name: providerLabel })}
|
||||
testID={`provider-actions-${providerId}`}
|
||||
>
|
||||
{({ hovered, open }) => (
|
||||
<MoreHorizontal
|
||||
size={theme.iconSize.sm}
|
||||
color={hovered || open ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
<DropdownMenuItem
|
||||
destructive
|
||||
leading={trashLeading}
|
||||
onSelect={handleRemove}
|
||||
status={isRemoving ? "pending" : "idle"}
|
||||
pendingLabel={t("settings.providers.actions.removing")}
|
||||
testID={`provider-remove-${providerId}`}
|
||||
>
|
||||
{t("settings.providers.actions.remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
@@ -74,9 +164,12 @@ function ProviderRow({
|
||||
entry,
|
||||
enabled,
|
||||
isToggling,
|
||||
isRemoving,
|
||||
canRemove,
|
||||
isFirst,
|
||||
onPress,
|
||||
onToggleEnabled,
|
||||
onRemove,
|
||||
}: ProviderRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
@@ -141,12 +234,22 @@ function ProviderRow({
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={handleToggleValueChange}
|
||||
disabled={isToggling}
|
||||
accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })}
|
||||
/>
|
||||
<View style={styles.trailingControls}>
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={handleToggleValueChange}
|
||||
disabled={isToggling || isRemoving}
|
||||
accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })}
|
||||
/>
|
||||
{canRemove ? (
|
||||
<ProviderActionsMenu
|
||||
providerId={def.id}
|
||||
providerLabel={def.label}
|
||||
isRemoving={isRemoving}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -203,10 +306,12 @@ export interface ProvidersSectionProps {
|
||||
export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval");
|
||||
const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
|
||||
const { patchConfig } = useDaemonConfig(serverId);
|
||||
const openProviderSettings = useProviderSettingsStore((state) => state.open);
|
||||
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
|
||||
const [removingProviderId, setRemovingProviderId] = useState<string | null>(null);
|
||||
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
|
||||
|
||||
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
|
||||
@@ -236,6 +341,33 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
[patchConfig, t],
|
||||
);
|
||||
|
||||
const handleRemoveProvider = useCallback(
|
||||
async (providerId: string, providerLabel: string) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("settings.providers.remove.confirmTitle", { name: providerLabel }),
|
||||
message: t("settings.providers.remove.confirmMessage"),
|
||||
confirmLabel: t("settings.providers.remove.confirm"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRemovingProviderId(providerId);
|
||||
try {
|
||||
await patchConfig({ removeProviders: [providerId] });
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
t("settings.providers.remove.errorTitle"),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setRemovingProviderId((current) => (current === providerId ? null : current));
|
||||
}
|
||||
},
|
||||
[patchConfig, t],
|
||||
);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
async (entry: AcpProviderCatalogItem) => {
|
||||
if (installingProviderId) return;
|
||||
@@ -284,9 +416,12 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
entry={entry}
|
||||
enabled={entry.enabled ?? true}
|
||||
isToggling={pendingProviderId === def.id}
|
||||
isRemoving={removingProviderId === def.id}
|
||||
canRemove={supportsProviderRemoval && entry.source === "custom"}
|
||||
isFirst={index === 0}
|
||||
onPress={handleOpenProviderSettings}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
onRemove={handleRemoveProvider}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -374,6 +509,24 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
trailingControls: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
menuButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
menuButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
menuButtonPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
}));
|
||||
|
||||
const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard];
|
||||
|
||||
@@ -104,6 +104,7 @@ export interface ProviderSnapshotEntry {
|
||||
provider: AgentProvider;
|
||||
status: ProviderStatus;
|
||||
enabled: boolean;
|
||||
source?: "builtin" | "custom";
|
||||
error?: string;
|
||||
models?: AgentModelDefinition[];
|
||||
modes?: AgentMode[];
|
||||
|
||||
@@ -368,4 +368,18 @@ describe("checkout PR schemas", () => {
|
||||
projectAdd: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts the provider removal server_info feature flag", () => {
|
||||
expect(
|
||||
ServerInfoStatusPayloadSchema.parse({
|
||||
status: "server_info",
|
||||
serverId: "srv_test",
|
||||
features: {
|
||||
providerRemoval: true,
|
||||
},
|
||||
}).features,
|
||||
).toEqual({
|
||||
providerRemoval: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,18 @@ describe("provider snapshot message schemas", () => {
|
||||
expect(parsed.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves provider snapshot entry source", () => {
|
||||
const parsed = ProviderSnapshotEntrySchema.parse({
|
||||
provider: "gemini",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
source: "custom",
|
||||
label: "Gemini",
|
||||
});
|
||||
|
||||
expect(parsed.source).toBe("custom");
|
||||
});
|
||||
|
||||
test("defaults missing enabled state in providers snapshot response entries", () => {
|
||||
const parsed = GetProvidersSnapshotResponseMessageSchema.parse({
|
||||
type: "get_providers_snapshot_response",
|
||||
|
||||
@@ -163,6 +163,7 @@ export const MutableDaemonConfigPatchSchema = z
|
||||
providers: z
|
||||
.record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough())
|
||||
.optional(),
|
||||
removeProviders: z.array(z.string().min(1)).optional(),
|
||||
metadataGeneration: MutableMetadataGenerationConfigSchema.partial().optional(),
|
||||
autoArchiveAfterMerge: z.boolean().optional(),
|
||||
enableTerminalAgentHooks: z.boolean().optional(),
|
||||
@@ -263,6 +264,7 @@ export const ProviderSnapshotEntrySchema = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
status: ProviderStatusSchema,
|
||||
enabled: z.boolean().optional().default(true),
|
||||
source: z.enum(["builtin", "custom"]).optional(),
|
||||
error: z.string().optional(),
|
||||
models: z.array(AgentModelDefinitionSchema).optional(),
|
||||
modes: z.array(AgentModeSchema).optional(),
|
||||
@@ -2471,6 +2473,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
workspaceGithubRepositorySearch: z.boolean().optional(),
|
||||
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
|
||||
projectCreateDirectory: z.boolean().optional(),
|
||||
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
|
||||
providerRemoval: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
@@ -105,6 +105,7 @@ export interface ProviderSnapshotEntry {
|
||||
provider: AgentProvider;
|
||||
status: ProviderStatus;
|
||||
enabled: boolean;
|
||||
source?: "builtin" | "custom";
|
||||
error?: string;
|
||||
models?: AgentModelDefinition[];
|
||||
modes?: AgentMode[];
|
||||
|
||||
@@ -952,6 +952,9 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => {
|
||||
expect(manager.hasProvider("zai-claude")).toBe(true);
|
||||
expect(state.providerDefinitions["zai-claude"]).toMatchObject({ enabled: true });
|
||||
expect(manager.listRegisteredProviderIds()).toContain("zai-claude");
|
||||
expect(manager.getSnapshot().find((entry) => entry.provider === "zai-claude")?.source).toBe(
|
||||
"custom",
|
||||
);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
shutdownAgentClients,
|
||||
type ProviderDefinition,
|
||||
} from "./provider-registry.js";
|
||||
import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
|
||||
import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js";
|
||||
import {
|
||||
formatProviderDiagnostic,
|
||||
@@ -503,6 +504,7 @@ export class ProviderSnapshotManager {
|
||||
provider,
|
||||
status: "error",
|
||||
enabled: definition.enabled,
|
||||
source: this.getProviderSource(provider),
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
defaultModeId: definition.defaultModeId,
|
||||
@@ -536,6 +538,11 @@ export class ProviderSnapshotManager {
|
||||
}
|
||||
}
|
||||
|
||||
private getProviderSource(provider: AgentProvider): ProviderSnapshotEntry["source"] {
|
||||
const isBuiltin = BUILTIN_PROVIDER_IDS.includes(provider);
|
||||
return !isBuiltin && this.providerOverrides?.[provider]?.extends ? "custom" : "builtin";
|
||||
}
|
||||
|
||||
private createLoadingEntries(): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||
const entries = new Map<AgentProvider, ProviderSnapshotEntry>();
|
||||
for (const provider of this.getProviderIds()) {
|
||||
@@ -544,6 +551,7 @@ export class ProviderSnapshotManager {
|
||||
provider,
|
||||
status: "loading",
|
||||
enabled: definition?.enabled ?? true,
|
||||
source: this.getProviderSource(provider),
|
||||
label: definition?.label,
|
||||
description: definition?.description,
|
||||
defaultModeId: definition?.defaultModeId ?? null,
|
||||
@@ -562,6 +570,7 @@ export class ProviderSnapshotManager {
|
||||
const metadata = {
|
||||
provider,
|
||||
enabled: definition?.enabled ?? true,
|
||||
source: this.getProviderSource(provider),
|
||||
label: definition?.label,
|
||||
description: definition?.description,
|
||||
defaultModeId: definition?.defaultModeId ?? null,
|
||||
@@ -725,6 +734,7 @@ export class ProviderSnapshotManager {
|
||||
const snapshot = this.getOrCreateSnapshot(snapshotCwd);
|
||||
const base = {
|
||||
provider,
|
||||
source: this.getProviderSource(provider),
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
defaultModeId: definition.defaultModeId,
|
||||
|
||||
@@ -119,6 +119,105 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch removes provider entries from config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
const configPath = path.join(paseoHome, "config.json");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
agents: {
|
||||
providers: {
|
||||
gemini: {
|
||||
extends: "acp",
|
||||
label: "Gemini",
|
||||
command: ["gemini", "--acp"],
|
||||
},
|
||||
claude: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
const store = new DaemonConfigStore(
|
||||
paseoHome,
|
||||
{
|
||||
mcp: { injectIntoAgents: false },
|
||||
browserTools: { enabled: false },
|
||||
providers: {
|
||||
gemini: {},
|
||||
claude: { enabled: false },
|
||||
},
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
enableTerminalAgentHooks: false,
|
||||
appendSystemPrompt: "",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
const next = store.patch({ removeProviders: ["gemini"] });
|
||||
|
||||
expect(next.providers.gemini).toBeUndefined();
|
||||
expect(next.providers.claude).toEqual({ enabled: false });
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.agents?.providers?.gemini).toBeUndefined();
|
||||
expect(persisted.agents?.providers?.claude).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
test("patch removes the providers object when the last provider is deleted", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
const configPath = path.join(paseoHome, "config.json");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
agents: {
|
||||
providers: {
|
||||
gemini: {
|
||||
extends: "acp",
|
||||
label: "Gemini",
|
||||
command: ["gemini", "--acp"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
const store = new DaemonConfigStore(
|
||||
paseoHome,
|
||||
{
|
||||
mcp: { injectIntoAgents: false },
|
||||
browserTools: { enabled: false },
|
||||
providers: { gemini: {} },
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
enableTerminalAgentHooks: false,
|
||||
appendSystemPrompt: "",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({ removeProviders: ["gemini"] });
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.agents?.providers).toBeUndefined();
|
||||
});
|
||||
|
||||
test("patch persists append system prompt into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -52,6 +52,53 @@ function deepMerge<T extends Record<string, unknown>>(
|
||||
return next as T;
|
||||
}
|
||||
|
||||
function omitProvidersFromConfig<T extends { providers?: Record<string, unknown> }>(
|
||||
config: T,
|
||||
providers: readonly string[],
|
||||
): T {
|
||||
if (providers.length === 0 || !config.providers) {
|
||||
return config;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const nextProviders = { ...config.providers };
|
||||
for (const provider of providers) {
|
||||
if (provider in nextProviders) {
|
||||
delete nextProviders[provider];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? ({ ...config, providers: nextProviders } as T) : config;
|
||||
}
|
||||
|
||||
function omitProvidersFromOverrides(
|
||||
overrides: Record<string, ProviderOverride> | undefined,
|
||||
providers: readonly string[],
|
||||
): Record<string, ProviderOverride> | undefined {
|
||||
if (!overrides) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nextOverrides = { ...overrides };
|
||||
for (const provider of providers) {
|
||||
delete nextOverrides[provider];
|
||||
}
|
||||
|
||||
return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined;
|
||||
}
|
||||
|
||||
function omitProvidersFromPersistedAgents(
|
||||
agents: PersistedConfig["agents"],
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!agents) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { providers: _providers, ...rest } = agents as Record<string, unknown>;
|
||||
return Object.keys(rest).length > 0 ? rest : undefined;
|
||||
}
|
||||
|
||||
function getValueAtPath(config: MutableDaemonConfig, path: string): unknown {
|
||||
return path
|
||||
.split(".")
|
||||
@@ -100,7 +147,9 @@ export class DaemonConfigStore {
|
||||
|
||||
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
|
||||
const parsedPatch = MutableDaemonConfigPatchSchema.parse(partial);
|
||||
const next = MutableDaemonConfigSchema.parse(deepMerge(this.current, parsedPatch));
|
||||
const { removeProviders = [], ...configPatch } = parsedPatch;
|
||||
const merged = deepMerge(this.current, configPatch);
|
||||
const next = MutableDaemonConfigSchema.parse(omitProvidersFromConfig(merged, removeProviders));
|
||||
|
||||
const changedFieldPaths = Array.from(this.fieldChangeHandlers.keys()).filter((path) => {
|
||||
return !isEqualValue(getValueAtPath(this.current, path), getValueAtPath(next, path));
|
||||
@@ -112,7 +161,7 @@ export class DaemonConfigStore {
|
||||
|
||||
// Persist before updating in-memory state so that if persistence fails,
|
||||
// runtime and disk stay consistent.
|
||||
this.persistConfig(next);
|
||||
this.persistConfig(next, removeProviders);
|
||||
this.current = next;
|
||||
|
||||
for (const path of changedFieldPaths) {
|
||||
@@ -157,11 +206,12 @@ export class DaemonConfigStore {
|
||||
};
|
||||
}
|
||||
|
||||
private persistConfig(config: MutableDaemonConfig): void {
|
||||
private persistConfig(config: MutableDaemonConfig, removeProviders: readonly string[]): void {
|
||||
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
|
||||
const nextPersisted = mergeMutableConfigIntoPersistedConfig({
|
||||
persisted,
|
||||
mutable: config,
|
||||
removeProviders,
|
||||
});
|
||||
savePersistedConfig(this.paseoHome, nextPersisted, this.logger);
|
||||
}
|
||||
@@ -170,22 +220,27 @@ export class DaemonConfigStore {
|
||||
function mergeMutableConfigIntoPersistedConfig(params: {
|
||||
persisted: PersistedConfig;
|
||||
mutable: MutableDaemonConfig;
|
||||
removeProviders: readonly string[];
|
||||
}): PersistedConfig {
|
||||
const { persisted, mutable } = params;
|
||||
const { persisted, mutable, removeProviders } = params;
|
||||
const browserToolsEnabled = readBrowserToolsEnabled(mutable);
|
||||
const metadataGenerationProviders = readMetadataGenerationProviders(mutable);
|
||||
const providerOverrides = applyMutableProviderConfigToOverrides(
|
||||
const persistedProviderOverrides = omitProvidersFromOverrides(
|
||||
persisted.agents?.providers as Record<string, ProviderOverride> | undefined,
|
||||
removeProviders,
|
||||
);
|
||||
const providerOverrides = applyMutableProviderConfigToOverrides(
|
||||
persistedProviderOverrides,
|
||||
mutable.providers,
|
||||
);
|
||||
const persistedAgents = persisted.agents as Record<string, unknown> | undefined;
|
||||
const persistedAgents = omitProvidersFromPersistedAgents(persisted.agents);
|
||||
const persistedMetadataGeneration = {
|
||||
providers: metadataGenerationProviders,
|
||||
};
|
||||
const shouldPersistMetadataGeneration =
|
||||
metadataGenerationProviders.length > 0 || persisted.agents?.metadataGeneration !== undefined;
|
||||
|
||||
let nextAgents = persisted.agents as PersistedConfig["agents"];
|
||||
let nextAgents = persistedAgents as PersistedConfig["agents"];
|
||||
if (providerOverrides && Object.keys(providerOverrides).length > 0) {
|
||||
nextAgents = {
|
||||
...persistedAgents,
|
||||
|
||||
@@ -1252,6 +1252,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
workspaceGithubRepositorySearch: true,
|
||||
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
|
||||
projectCreateDirectory: true,
|
||||
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
|
||||
providerRemoval: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user