Inline provider catalog in settings (#1423)

Replace the modal Add Provider flow with an inline Add provider section
below the configured providers list. Makes new providers discoverable
without opening a modal.

- Remove add-provider-modal component
- Add provider-catalog-list component with search + Add button
- Remove duplicate ACP catalog entries for built-in providers
- Add spacing between the two sections
This commit is contained in:
Mohamed Boudra
2026-06-09 10:44:37 +08:00
committed by GitHub
parent 0cbb8238c2
commit 02ec937399
6 changed files with 89 additions and 199 deletions
@@ -4,7 +4,7 @@ import { getServerId } from "./helpers/server-id";
import { import {
expectProviderInstalledInSettings, expectProviderInstalledInSettings,
installAcpCatalogProvider, installAcpCatalogProvider,
openAddProviderModal, openAddProviderArea,
openSettingsHost, openSettingsHost,
openSettingsHostSection, openSettingsHostSection,
} from "./helpers/settings"; } from "./helpers/settings";
@@ -21,7 +21,7 @@ test.describe("ACP provider catalog", () => {
await openSettingsHost(page, getServerId()); await openSettingsHost(page, getServerId());
// Providers moved to their own host section; add-provider lives there now. // Providers moved to their own host section; add-provider lives there now.
await openSettingsHostSection(page, getServerId(), "providers"); await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderModal(page); await openAddProviderArea(page);
await installAcpCatalogProvider(page, ACP_PROVIDER.name); await installAcpCatalogProvider(page, ACP_PROVIDER.name);
await expectProviderInstalledInSettings(page, ACP_PROVIDER.name); await expectProviderInstalledInSettings(page, ACP_PROVIDER.name);
+2 -3
View File
@@ -319,8 +319,8 @@ export async function serveJson(page: Page, url: string, body: unknown): Promise
}); });
} }
export async function openAddProviderModal(page: Page): Promise<void> { export async function openAddProviderArea(page: Page): Promise<void> {
await page.getByRole("button", { name: "Add provider", exact: true }).click(); await page.getByTestId("host-page-add-provider-card").scrollIntoViewIfNeeded();
await expect(page.getByRole("textbox", { name: "Search providers" })).toBeVisible(); await expect(page.getByRole("textbox", { name: "Search providers" })).toBeVisible();
} }
@@ -332,7 +332,6 @@ export async function findAcpCatalogProvider(page: Page, providerName: string):
export async function installAcpCatalogProvider(page: Page, providerName: string): Promise<void> { export async function installAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
await findAcpCatalogProvider(page, providerName); await findAcpCatalogProvider(page, providerName);
await page.getByRole("button", { name: "Add", exact: true }).click(); await page.getByRole("button", { name: "Add", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Search providers" })).toHaveCount(0);
} }
export async function expectProviderInstalledInSettings( export async function expectProviderInstalledInSettings(
@@ -1,36 +1,24 @@
import { useCallback, useMemo, useReducer, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import { SvgXml } from "react-native-svg"; import { SvgXml } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ExternalLink, PackagePlus, Search } from "lucide-react-native"; import { ExternalLink, PackagePlus, Search } from "lucide-react-native";
import { import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
buildAcpProviderConfigPatch,
useAcpProviderCatalog, useAcpProviderCatalog,
type AcpProviderCatalogItem, type AcpProviderCatalogItem,
} from "@/hooks/use-acp-provider-catalog"; } from "@/hooks/use-acp-provider-catalog";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
interface AddProviderModalProps { interface ProviderCatalogListProps {
serverId: string; serverId: string;
visible: boolean; installingProviderId: string | null;
onClose: () => void; onInstall: (entry: AcpProviderCatalogItem) => Promise<void> | void;
} }
type InstallState = "installed" | "available";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const ACTION_BUTTON_STYLE = { width: 92 } as const;
const MODAL_SNAP_POINTS = ["78%", "92%"];
const ADD_PROVIDER_HEADER: SheetHeader = { title: "Add provider" };
const SEARCH_ICON_SIZE = 16; const SEARCH_ICON_SIZE = 16;
const PROVIDER_FALLBACK_ICON_SIZE = 20; const PROVIDER_FALLBACK_ICON_SIZE = 20;
const PROVIDER_REMOTE_ICON_SIZE = 24; const PROVIDER_REMOTE_ICON_SIZE = 24;
@@ -45,14 +33,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}); });
function getInstallState(
entry: AcpProviderCatalogItem,
installedProviderIds: Set<string>,
): InstallState {
if (installedProviderIds.has(entry.id)) return "installed";
return "available";
}
function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean { function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean {
const normalized = query.trim().toLowerCase(); const normalized = query.trim().toLowerCase();
if (!normalized) return true; if (!normalized) return true;
@@ -61,22 +41,13 @@ function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean {
); );
} }
interface ProviderCatalogRowProps { interface CatalogRowProps {
entry: AcpProviderCatalogItem; entry: AcpProviderCatalogItem;
state: InstallState;
installing: boolean; installing: boolean;
onInstall: (entry: AcpProviderCatalogItem) => void; onInstall: (entry: AcpProviderCatalogItem) => void;
} }
function ProviderCatalogRow({ entry, state, installing, onInstall }: ProviderCatalogRowProps) { function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
const isAvailable = state === "available";
let actionLabel = "Add";
if (installing) {
actionLabel = "Adding";
} else if (state === "installed") {
actionLabel = "Installed";
}
const handleInstall = useCallback(() => { const handleInstall = useCallback(() => {
onInstall(entry); onInstall(entry);
}, [entry, onInstall]); }, [entry, onInstall]);
@@ -125,72 +96,43 @@ function ProviderCatalogRow({ entry, state, installing, onInstall }: ProviderCat
</View> </View>
<Button <Button
size="sm" size="sm"
variant={isAvailable ? "default" : "secondary"} variant="default"
disabled={!isAvailable || installing} disabled={installing}
loading={installing} loading={installing}
onPress={handleInstall} onPress={handleInstall}
style={ACTION_BUTTON_STYLE} style={styles.actionButton}
testID={`install-provider-${entry.id}`} testID={`install-provider-${entry.id}`}
> >
{actionLabel} {installing ? "Adding" : "Add"}
</Button> </Button>
</View> </View>
); );
} }
export function AddProviderModal({ serverId, visible, onClose }: AddProviderModalProps) { export function ProviderCatalogList({
const { entries } = useAcpProviderCatalog(); serverId,
const { entries: providerEntries, refresh } = useProvidersSnapshot(serverId); installingProviderId,
const { patchConfig } = useDaemonConfig(serverId); onInstall,
}: ProviderCatalogListProps) {
const { entries: catalogEntries } = useAcpProviderCatalog();
const { entries: providerEntries } = useProvidersSnapshot(serverId);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
const handleClose = useCallback(() => { const installedIds = useMemo(
setSearch("");
bumpSearchResetKey();
onClose();
}, [onClose]);
const installedProviderIds = useMemo(
() => new Set(providerEntries?.map((entry) => entry.provider) ?? []), () => new Set(providerEntries?.map((entry) => entry.provider) ?? []),
[providerEntries], [providerEntries],
); );
const filteredEntries = useMemo(
() => entries.filter((entry) => matchesSearch(entry, search)),
[entries, search],
);
const handleInstall = useCallback( const availableEntries = useMemo(
async (entry: AcpProviderCatalogItem) => { () =>
if (installingProviderId) return; catalogEntries
.filter((entry) => !installedIds.has(entry.id))
setInstallingProviderId(entry.id); .filter((entry) => matchesSearch(entry, search)),
try { [catalogEntries, installedIds, search],
await patchConfig(buildAcpProviderConfigPatch(entry));
await refresh([entry.id]);
handleClose();
} catch (installError) {
Alert.alert(
"Unable to install provider",
installError instanceof Error ? installError.message : String(installError),
);
} finally {
setInstallingProviderId((current) => (current === entry.id ? null : current));
}
},
[installingProviderId, handleClose, patchConfig, refresh],
); );
return ( return (
<AdaptiveModalSheet <View>
header={ADD_PROVIDER_HEADER}
visible={visible}
onClose={handleClose}
desktopMaxWidth={680}
snapPoints={MODAL_SNAP_POINTS}
testID="add-provider-modal"
>
<View style={styles.searchField}> <View style={styles.searchField}>
<View style={styles.searchIcon}> <View style={styles.searchIcon}>
<ThemedSearch size={SEARCH_ICON_SIZE} uniProps={foregroundMutedColorMapping} /> <ThemedSearch size={SEARCH_ICON_SIZE} uniProps={foregroundMutedColorMapping} />
@@ -198,8 +140,6 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
<AdaptiveTextInput <AdaptiveTextInput
testID="provider-catalog-search" testID="provider-catalog-search"
accessibilityLabel="Search providers" accessibilityLabel="Search providers"
initialValue={search}
resetKey={`provider-catalog-search-${searchResetKey}`}
value={search} value={search}
onChangeText={setSearch} onChangeText={setSearch}
placeholder="Search providers" placeholder="Search providers"
@@ -209,32 +149,25 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
/> />
</View> </View>
{filteredEntries.length === 0 ? ( {availableEntries.length === 0 ? (
<View style={styles.stateBox}> <View style={styles.stateBox}>
<Text style={styles.stateText}>No providers found</Text> <Text style={styles.stateText}>
{search.trim().length > 0 ? "No providers found" : "All providers are installed"}
</Text>
</View> </View>
) : null} ) : (
{filteredEntries.length > 0 ? (
<View style={styles.list}> <View style={styles.list}>
{filteredEntries.map((entry) => ( {availableEntries.map((entry) => (
<ProviderCatalogRow <CatalogRow
key={entry.id} key={entry.id}
entry={entry} entry={entry}
state={getInstallState(entry, installedProviderIds)}
installing={installingProviderId === entry.id} installing={installingProviderId === entry.id}
onInstall={handleInstall} onInstall={onInstall}
/> />
))} ))}
</View> </View>
) : null} )}
</View>
<View style={styles.actions}>
<Button style={FLEX_ONE_STYLE} variant="secondary" onPress={handleClose}>
Cancel
</Button>
</View>
</AdaptiveModalSheet>
); );
} }
@@ -248,6 +181,7 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.border, borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
marginBottom: theme.spacing[3],
}, },
searchIcon: { searchIcon: {
width: 18, width: 18,
@@ -320,6 +254,10 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
}, },
actionButton: {
width: 92,
flexShrink: 0,
},
stateBox: { stateBox: {
minHeight: 96, minHeight: 96,
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
@@ -335,7 +273,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
actions: {
flexDirection: "row",
},
})); }));
@@ -53,15 +53,6 @@ const CATALOG_DATA = [
installLink: "https://www.autohand.ai/cli/", installLink: "https://www.autohand.ai/cli/",
command: ["npx", "-y", "@autohandai/autohand-acp@0.2.1"], command: ["npx", "-y", "@autohandai/autohand-acp@0.2.1"],
}, },
{
id: "claude-acp",
title: "Claude Agent",
description: "ACP wrapper for Anthropic's Claude",
version: "0.42.0",
iconId: "claude-acp",
installLink: "https://github.com/agentclientprotocol/claude-agent-acp",
command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.42.0"],
},
{ {
id: "cline", id: "cline",
title: "Cline", title: "Cline",
@@ -81,15 +72,6 @@ const CATALOG_DATA = [
installLink: "https://www.codebuddy.cn/cli/", installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.103.4", "--acp"], command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.103.4", "--acp"],
}, },
{
id: "codex-acp",
title: "Codex CLI",
description: "ACP adapter for OpenAI's coding assistant",
version: "0.13.0",
iconId: "codex-acp",
installLink: "https://github.com/zed-industries/codex-acp",
command: ["codex-acp"],
},
{ {
id: "cortex-code", id: "cortex-code",
title: "Cortex Code", title: "Cortex Code",
@@ -203,15 +185,6 @@ const CATALOG_DATA = [
installLink: "https://geminicli.com", installLink: "https://geminicli.com",
command: ["npx", "-y", "@google/gemini-cli@0.45.2", "--acp"], command: ["npx", "-y", "@google/gemini-cli@0.45.2", "--acp"],
}, },
{
id: "github-copilot-cli",
title: "GitHub Copilot",
description: "GitHub's AI pair programmer",
version: "1.0.60",
iconId: "github-copilot-cli",
installLink: "https://github.com/features/copilot/cli/",
command: ["npx", "-y", "@github/copilot@1.0.60", "--acp"],
},
{ {
id: "glm-acp-agent", id: "glm-acp-agent",
title: "GLM Agent", title: "GLM Agent",
@@ -314,24 +287,6 @@ const CATALOG_DATA = [
installLink: "https://www.compassap.ai/portfolio/nova.html", installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.15", "acp"], command: ["npx", "-y", "@compass-ai/nova@1.1.15", "acp"],
}, },
{
id: "opencode",
title: "OpenCode",
description: "The open source coding agent",
version: "1.14.39",
iconId: "opencode",
installLink: "https://opencode.ai/docs/acp/",
command: ["opencode", "acp"],
},
{
id: "pi-acp",
title: "pi ACP",
description: "ACP adapter for pi coding agent",
version: "0.0.27",
iconId: "pi-acp",
installLink: "https://github.com/svkozak/pi-acp",
command: ["npx", "-y", "pi-acp@0.0.27"],
},
{ {
id: "poolside", id: "poolside",
title: "Poolside", title: "Poolside",
@@ -97,7 +97,6 @@ vi.mock("lucide-react-native", () => {
const icon = (name: string) => () => React.createElement("span", { "data-icon": name }); const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
return { return {
ChevronRight: icon("ChevronRight"), ChevronRight: icon("ChevronRight"),
Plus: icon("Plus"),
}; };
}); });
@@ -143,8 +142,8 @@ vi.mock("@/stores/provider-settings-store", () => ({
selector({ open: openProviderSettingsMock }), selector({ open: openProviderSettingsMock }),
})); }));
vi.mock("@/components/add-provider-modal", () => ({ vi.mock("@/components/provider-catalog-list", () => ({
AddProviderModal: () => null, ProviderCatalogList: () => null,
})); }));
vi.mock("@/hooks/use-providers-snapshot", () => ({ vi.mock("@/hooks/use-providers-snapshot", () => ({
@@ -6,13 +6,17 @@ import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { buildProviderDefinitions } from "@/utils/provider-definitions"; import { buildProviderDefinitions } from "@/utils/provider-definitions";
import { AddProviderModal } from "@/components/add-provider-modal"; import {
buildAcpProviderConfigPatch,
type AcpProviderCatalogItem,
} from "@/hooks/use-acp-provider-catalog";
import { ProviderCatalogList } from "@/components/provider-catalog-list";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/screens/settings/settings-section"; import { SettingsSection } from "@/screens/settings/settings-section";
import { useProviderSettingsStore } from "@/stores/provider-settings-store"; import { useProviderSettingsStore } from "@/stores/provider-settings-store";
import { ChevronRight, Plus } from "lucide-react-native"; import { ChevronRight } from "lucide-react-native";
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number]; type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number]; type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
@@ -177,13 +181,12 @@ export interface ProvidersSectionProps {
} }
export function ProvidersSection({ serverId }: ProvidersSectionProps) { export function ProvidersSection({ serverId }: ProvidersSectionProps) {
const { theme } = useUnistyles();
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const { entries, isLoading } = useProvidersSnapshot(serverId); const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
const { patchConfig } = useDaemonConfig(serverId); const { patchConfig } = useDaemonConfig(serverId);
const openProviderSettings = useProviderSettingsStore((state) => state.open); const openProviderSettings = useProviderSettingsStore((state) => state.open);
const [isAddProviderOpen, setIsAddProviderOpen] = useState(false);
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null); const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]); const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
const hasServer = serverId.length > 0; const hasServer = serverId.length > 0;
@@ -194,8 +197,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
}, },
[openProviderSettings, serverId], [openProviderSettings, serverId],
); );
const handleOpenAddProvider = useCallback(() => setIsAddProviderOpen(true), []);
const handleCloseAddProvider = useCallback(() => setIsAddProviderOpen(false), []);
const handleToggleEnabled = useCallback( const handleToggleEnabled = useCallback(
async (providerId: string, enabled: boolean) => { async (providerId: string, enabled: boolean) => {
setPendingProviderId(providerId); setPendingProviderId(providerId);
@@ -213,37 +215,29 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
[patchConfig], [patchConfig],
); );
const headerActions = useMemo( const handleInstall = useCallback(
() => async (entry: AcpProviderCatalogItem) => {
hasServer && isConnected ? ( if (installingProviderId) return;
<View style={styles.headerActions}> setInstallingProviderId(entry.id);
<Pressable try {
onPress={handleOpenAddProvider} await patchConfig(buildAcpProviderConfigPatch(entry));
hitSlop={8} await refresh([entry.id]);
style={settingsStyles.sectionHeaderLink} } catch (error) {
accessibilityRole="button" Alert.alert(
accessibilityLabel="Add provider" "Unable to add provider",
testID="add-provider-button" error instanceof Error ? error.message : String(error),
> );
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> } finally {
<Text style={settingsStyles.sectionHeaderLinkText}>Add provider</Text> setInstallingProviderId((current) => (current === entry.id ? null : current));
</Pressable> }
</View> },
) : undefined, [installingProviderId, patchConfig, refresh],
[
hasServer,
isConnected,
handleOpenAddProvider,
theme.iconSize.sm,
theme.colors.foregroundMuted,
],
); );
return ( return (
<> <>
<SettingsSection <SettingsSection
title="Providers" title="Providers"
trailing={headerActions}
testID="host-page-providers-card" testID="host-page-providers-card"
style={styles.sectionSpacing} style={styles.sectionSpacing}
> >
@@ -279,8 +273,18 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
) : null} ) : null}
</SettingsSection> </SettingsSection>
{hasServer && isConnected && isAddProviderOpen ? ( {hasServer && isConnected ? (
<AddProviderModal serverId={serverId} visible onClose={handleCloseAddProvider} /> <SettingsSection
title="Add provider"
testID="host-page-add-provider-card"
style={styles.addProviderSection}
>
<ProviderCatalogList
serverId={serverId}
installingProviderId={installingProviderId}
onInstall={handleInstall}
/>
</SettingsSection>
) : null} ) : null}
</> </>
); );
@@ -290,6 +294,9 @@ const styles = StyleSheet.create((theme) => ({
sectionSpacing: { sectionSpacing: {
marginBottom: theme.spacing[4], marginBottom: theme.spacing[4],
}, },
addProviderSection: {
marginTop: theme.spacing[4],
},
emptyCard: { emptyCard: {
padding: theme.spacing[4], padding: theme.spacing[4],
alignItems: "center", alignItems: "center",
@@ -298,11 +305,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
row: { row: {
gap: theme.spacing[3], gap: theme.spacing[3],
minHeight: 56, minHeight: 56,