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:
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+42
-107
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user