diff --git a/packages/app/e2e/acp-provider-catalog.spec.ts b/packages/app/e2e/acp-provider-catalog.spec.ts index 1b1439d37..7671d325c 100644 --- a/packages/app/e2e/acp-provider-catalog.spec.ts +++ b/packages/app/e2e/acp-provider-catalog.spec.ts @@ -4,7 +4,7 @@ import { getServerId } from "./helpers/server-id"; import { expectProviderInstalledInSettings, installAcpCatalogProvider, - openAddProviderModal, + openAddProviderArea, openSettingsHost, openSettingsHostSection, } from "./helpers/settings"; @@ -21,7 +21,7 @@ test.describe("ACP provider catalog", () => { await openSettingsHost(page, getServerId()); // Providers moved to their own host section; add-provider lives there now. await openSettingsHostSection(page, getServerId(), "providers"); - await openAddProviderModal(page); + await openAddProviderArea(page); await installAcpCatalogProvider(page, ACP_PROVIDER.name); await expectProviderInstalledInSettings(page, ACP_PROVIDER.name); diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/helpers/settings.ts index 2f91b5aab..e2cff2b36 100644 --- a/packages/app/e2e/helpers/settings.ts +++ b/packages/app/e2e/helpers/settings.ts @@ -319,8 +319,8 @@ export async function serveJson(page: Page, url: string, body: unknown): Promise }); } -export async function openAddProviderModal(page: Page): Promise { - await page.getByRole("button", { name: "Add provider", exact: true }).click(); +export async function openAddProviderArea(page: Page): Promise { + await page.getByTestId("host-page-add-provider-card").scrollIntoViewIfNeeded(); 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 { await findAcpCatalogProvider(page, providerName); await page.getByRole("button", { name: "Add", exact: true }).click(); - await expect(page.getByRole("textbox", { name: "Search providers" })).toHaveCount(0); } export async function expectProviderInstalledInSettings( diff --git a/packages/app/src/components/add-provider-modal.tsx b/packages/app/src/components/provider-catalog-list.tsx similarity index 63% rename from packages/app/src/components/add-provider-modal.tsx rename to packages/app/src/components/provider-catalog-list.tsx index 61ba4b4bc..e9c8fa700 100644 --- a/packages/app/src/components/add-provider-modal.tsx +++ b/packages/app/src/components/provider-catalog-list.tsx @@ -1,36 +1,24 @@ -import { useCallback, useMemo, useReducer, useState } from "react"; -import { Alert, Pressable, Text, View } from "react-native"; +import { useCallback, useMemo, useState } from "react"; +import { Pressable, Text, View } from "react-native"; import { SvgXml } from "react-native-svg"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { ExternalLink, PackagePlus, Search } from "lucide-react-native"; -import { - AdaptiveModalSheet, - AdaptiveTextInput, - type SheetHeader, -} from "@/components/adaptive-modal-sheet"; +import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; import { - buildAcpProviderConfigPatch, useAcpProviderCatalog, type AcpProviderCatalogItem, } from "@/hooks/use-acp-provider-catalog"; -import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import type { Theme } from "@/styles/theme"; import { openExternalUrl } from "@/utils/open-external-url"; -interface AddProviderModalProps { +interface ProviderCatalogListProps { serverId: string; - visible: boolean; - onClose: () => void; + installingProviderId: string | null; + onInstall: (entry: AcpProviderCatalogItem) => Promise | 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 PROVIDER_FALLBACK_ICON_SIZE = 20; const PROVIDER_REMOTE_ICON_SIZE = 24; @@ -45,14 +33,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); -function getInstallState( - entry: AcpProviderCatalogItem, - installedProviderIds: Set, -): InstallState { - if (installedProviderIds.has(entry.id)) return "installed"; - return "available"; -} - function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean { const normalized = query.trim().toLowerCase(); if (!normalized) return true; @@ -61,22 +41,13 @@ function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean { ); } -interface ProviderCatalogRowProps { +interface CatalogRowProps { entry: AcpProviderCatalogItem; - state: InstallState; installing: boolean; onInstall: (entry: AcpProviderCatalogItem) => void; } -function ProviderCatalogRow({ entry, state, installing, onInstall }: ProviderCatalogRowProps) { - const isAvailable = state === "available"; - let actionLabel = "Add"; - if (installing) { - actionLabel = "Adding"; - } else if (state === "installed") { - actionLabel = "Installed"; - } - +function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) { const handleInstall = useCallback(() => { onInstall(entry); }, [entry, onInstall]); @@ -125,72 +96,43 @@ function ProviderCatalogRow({ entry, state, installing, onInstall }: ProviderCat ); } -export function AddProviderModal({ serverId, visible, onClose }: AddProviderModalProps) { - const { entries } = useAcpProviderCatalog(); - const { entries: providerEntries, refresh } = useProvidersSnapshot(serverId); - const { patchConfig } = useDaemonConfig(serverId); +export function ProviderCatalogList({ + serverId, + installingProviderId, + onInstall, +}: ProviderCatalogListProps) { + const { entries: catalogEntries } = useAcpProviderCatalog(); + const { entries: providerEntries } = useProvidersSnapshot(serverId); const [search, setSearch] = useState(""); - const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0); - const [installingProviderId, setInstallingProviderId] = useState(null); - const handleClose = useCallback(() => { - setSearch(""); - bumpSearchResetKey(); - onClose(); - }, [onClose]); - - const installedProviderIds = useMemo( + const installedIds = useMemo( () => new Set(providerEntries?.map((entry) => entry.provider) ?? []), [providerEntries], ); - const filteredEntries = useMemo( - () => entries.filter((entry) => matchesSearch(entry, search)), - [entries, search], - ); - const handleInstall = useCallback( - async (entry: AcpProviderCatalogItem) => { - if (installingProviderId) return; - - setInstallingProviderId(entry.id); - try { - 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], + const availableEntries = useMemo( + () => + catalogEntries + .filter((entry) => !installedIds.has(entry.id)) + .filter((entry) => matchesSearch(entry, search)), + [catalogEntries, installedIds, search], ); return ( - + @@ -198,8 +140,6 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa - {filteredEntries.length === 0 ? ( + {availableEntries.length === 0 ? ( - No providers found + + {search.trim().length > 0 ? "No providers found" : "All providers are installed"} + - ) : null} - - {filteredEntries.length > 0 ? ( + ) : ( - {filteredEntries.map((entry) => ( - ( + ))} - ) : null} - - - - - + )} + ); } @@ -248,6 +181,7 @@ const styles = StyleSheet.create((theme) => ({ borderWidth: 1, borderColor: theme.colors.border, paddingHorizontal: theme.spacing[3], + marginBottom: theme.spacing[3], }, searchIcon: { width: 18, @@ -320,6 +254,10 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, + actionButton: { + width: 92, + flexShrink: 0, + }, stateBox: { minHeight: 96, borderRadius: theme.borderRadius.lg, @@ -335,7 +273,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, - actions: { - flexDirection: "row", - }, })); diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index 0dd98c557..ac8ef6d36 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -53,15 +53,6 @@ const CATALOG_DATA = [ installLink: "https://www.autohand.ai/cli/", 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", title: "Cline", @@ -81,15 +72,6 @@ const CATALOG_DATA = [ installLink: "https://www.codebuddy.cn/cli/", 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", title: "Cortex Code", @@ -203,15 +185,6 @@ const CATALOG_DATA = [ installLink: "https://geminicli.com", 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", title: "GLM Agent", @@ -314,24 +287,6 @@ const CATALOG_DATA = [ installLink: "https://www.compassap.ai/portfolio/nova.html", 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", title: "Poolside", diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index eea768e10..59aa6cd10 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -97,7 +97,6 @@ vi.mock("lucide-react-native", () => { const icon = (name: string) => () => React.createElement("span", { "data-icon": name }); return { ChevronRight: icon("ChevronRight"), - Plus: icon("Plus"), }; }); @@ -143,8 +142,8 @@ vi.mock("@/stores/provider-settings-store", () => ({ selector({ open: openProviderSettingsMock }), })); -vi.mock("@/components/add-provider-modal", () => ({ - AddProviderModal: () => null, +vi.mock("@/components/provider-catalog-list", () => ({ + ProviderCatalogList: () => null, })); vi.mock("@/hooks/use-providers-snapshot", () => ({ diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index d4f0c3c7f..0967f5ead 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -6,13 +6,17 @@ 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 { 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 { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Switch } from "@/components/ui/switch"; import { SettingsSection } from "@/screens/settings/settings-section"; import { useProviderSettingsStore } from "@/stores/provider-settings-store"; -import { ChevronRight, Plus } from "lucide-react-native"; +import { ChevronRight } from "lucide-react-native"; type ProviderDefinition = ReturnType[number]; type ProviderEntry = NonNullable["entries"]>[number]; @@ -177,13 +181,12 @@ export interface ProvidersSectionProps { } export function ProvidersSection({ serverId }: ProvidersSectionProps) { - const { theme } = useUnistyles(); const isConnected = useHostRuntimeIsConnected(serverId); - const { entries, isLoading } = useProvidersSnapshot(serverId); + const { entries, isLoading, refresh } = useProvidersSnapshot(serverId); const { patchConfig } = useDaemonConfig(serverId); const openProviderSettings = useProviderSettingsStore((state) => state.open); - const [isAddProviderOpen, setIsAddProviderOpen] = useState(false); const [pendingProviderId, setPendingProviderId] = useState(null); + const [installingProviderId, setInstallingProviderId] = useState(null); const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]); const hasServer = serverId.length > 0; @@ -194,8 +197,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { }, [openProviderSettings, serverId], ); - const handleOpenAddProvider = useCallback(() => setIsAddProviderOpen(true), []); - const handleCloseAddProvider = useCallback(() => setIsAddProviderOpen(false), []); + const handleToggleEnabled = useCallback( async (providerId: string, enabled: boolean) => { setPendingProviderId(providerId); @@ -213,37 +215,29 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { [patchConfig], ); - const headerActions = useMemo( - () => - hasServer && isConnected ? ( - - - - Add provider - - - ) : undefined, - [ - hasServer, - isConnected, - handleOpenAddProvider, - theme.iconSize.sm, - theme.colors.foregroundMuted, - ], + const handleInstall = useCallback( + async (entry: AcpProviderCatalogItem) => { + if (installingProviderId) return; + setInstallingProviderId(entry.id); + try { + await patchConfig(buildAcpProviderConfigPatch(entry)); + await refresh([entry.id]); + } catch (error) { + Alert.alert( + "Unable to add provider", + error instanceof Error ? error.message : String(error), + ); + } finally { + setInstallingProviderId((current) => (current === entry.id ? null : current)); + } + }, + [installingProviderId, patchConfig, refresh], ); return ( <> @@ -279,8 +273,18 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { ) : null} - {hasServer && isConnected && isAddProviderOpen ? ( - + {hasServer && isConnected ? ( + + + ) : null} ); @@ -290,6 +294,9 @@ const styles = StyleSheet.create((theme) => ({ sectionSpacing: { marginBottom: theme.spacing[4], }, + addProviderSection: { + marginTop: theme.spacing[4], + }, emptyCard: { padding: theme.spacing[4], alignItems: "center", @@ -298,11 +305,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, - headerActions: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - }, row: { gap: theme.spacing[3], minHeight: 56,