Refine provider catalog buttons and show Add errors as toasts (#7030)

* fix(app): refine provider catalog actions and error feedback

Use compact row tiles and the shared error toast so failed Add actions remain visible while browsing the catalog. Point Hoplite installation instructions at the CLI install section.

* test(app): supply toast context in provider settings tests

Import the toast API independently of its renderer so the provider section only depends on the context it consumes.

(cherry picked from commit 2d86b94b2f)
This commit is contained in:
Mohamed Boudra
2026-10-15 18:26:49 +02:00
parent 5d6a6bda1f
commit 84c0239448
6 changed files with 69 additions and 96 deletions
@@ -140,8 +140,10 @@ test("unsupported host asks for an update without writing Hoplite config", async
await openSettingsHost(page, outdatedDaemon.serverId);
await openSettingsHostSection(page, outdatedDaemon.serverId, "providers");
await openAddProviderArea(page);
await installAcpCatalogProvider(page, "Hoplite");
await expect(page.getByRole("alert")).toHaveText("Update the host to add Hoplite.");
await page.getByRole("button", { name: "Add Hoplite", exact: true }).click();
const errorToast = page.getByTestId("app-toast-message");
await expect(errorToast).toHaveText("Update the host to add Hoplite.");
await expect(errorToast).toBeInViewport();
expect((await client.getDaemonConfig()).config).toEqual(before.config);
await expect(
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
@@ -26,8 +26,8 @@ interface ProviderCatalogListProps {
const PROVIDER_FALLBACK_ICON_SIZE = 20;
const PROVIDER_REMOTE_ICON_SIZE = 24;
const TILE_GAP = SPACING[3];
/** Narrowest tile that keeps the type badge and install link on one line. */
const MIN_TILE_WIDTH = 216;
/** Narrowest tile that keeps the icon, type badge, install link, and Add on one row. */
const MIN_TILE_WIDTH = 300;
const MAX_COLUMNS = 4;
const ThemedPackagePlus = withUnistyles(PackagePlus);
@@ -42,7 +42,7 @@ function columnCountForWidth(width: number): number {
interface CatalogTileProps {
entry: AcpProviderCatalogItem;
/** `undefined` until the grid is measured; a single column fills the row. */
/** `undefined` until the grid is measured, or in a single column; the tile fills the row. */
width: number | undefined;
installing: boolean;
onInstall: (entry: AcpProviderCatalogItem) => void;
@@ -53,76 +53,53 @@ function CatalogTile({ entry, width, installing, onInstall }: CatalogTileProps)
const handleInstall = useCallback(() => {
onInstall(entry);
}, [entry, onInstall]);
const isSingleColumn = width === undefined;
const tileStyle = useMemo(
() => [settingsStyles.card, styles.tile, isSingleColumn ? styles.tileFull : { width }],
[isSingleColumn, width],
);
const icon = (
<View style={styles.iconFrame}>
{entry.iconSvg ? (
<ThemedSvgXml
xml={entry.iconSvg}
width={PROVIDER_REMOTE_ICON_SIZE}
height={PROVIDER_REMOTE_ICON_SIZE}
uniProps={foregroundColorMapping}
/>
) : (
<ThemedPackagePlus size={PROVIDER_FALLBACK_ICON_SIZE} uniProps={foregroundColorMapping} />
)}
</View>
);
const addButton = (
<Button
size="sm"
variant="outline"
disabled={installing}
loading={installing}
onPress={handleInstall}
accessibilityLabel={t("providerCatalog.actions.addProvider", { provider: entry.title })}
style={styles.addButton}
testID={`install-provider-${entry.id}`}
>
{installing ? t("providerCatalog.actions.adding") : t("providerCatalog.actions.add")}
</Button>
);
const details = (
<View style={isSingleColumn ? [styles.details, styles.detailsInRow] : styles.details}>
<Text style={styles.name} numberOfLines={1}>
{entry.title}
</Text>
<View style={styles.metaRow}>
{/* The catalog is the ACP registry, so every entry it offers is an ACP provider. */}
<StatusBadge label={t("providerCatalog.types.acp")} size="xs" />
<ExternalLink
href={entry.installLink}
label={t("providerCatalog.actions.installInstructions")}
accessibilityLabel={t("providerCatalog.actions.installInstructionsFor", {
provider: entry.title,
})}
/>
</View>
</View>
() => [settingsStyles.card, styles.tile, width === undefined ? styles.tileFull : { width }],
[width],
);
return (
<View style={tileStyle} testID={`provider-catalog-tile-${entry.id}`}>
{isSingleColumn ? (
<View style={styles.singleColumnRow}>
{icon}
{details}
{addButton}
<View style={styles.iconFrame}>
{entry.iconSvg ? (
<ThemedSvgXml
xml={entry.iconSvg}
width={PROVIDER_REMOTE_ICON_SIZE}
height={PROVIDER_REMOTE_ICON_SIZE}
uniProps={foregroundColorMapping}
/>
) : (
<ThemedPackagePlus size={PROVIDER_FALLBACK_ICON_SIZE} uniProps={foregroundColorMapping} />
)}
</View>
<View style={styles.details}>
<Text style={styles.name} numberOfLines={1}>
{entry.title}
</Text>
<View style={styles.metaRow}>
{/* The catalog is the ACP registry, so every entry it offers is an ACP provider. */}
<StatusBadge label={t("providerCatalog.types.acp")} size="xs" />
<ExternalLink
href={entry.installLink}
label={t("providerCatalog.actions.installInstructions")}
accessibilityLabel={t("providerCatalog.actions.installInstructionsFor", {
provider: entry.title,
})}
/>
</View>
) : (
<>
<View style={styles.tileHeader}>
{icon}
{addButton}
</View>
{details}
</>
)}
</View>
<Button
size="xs"
variant="outline"
disabled={installing}
loading={installing}
onPress={handleInstall}
accessibilityLabel={t("providerCatalog.actions.addProvider", { provider: entry.title })}
style={styles.addButton}
testID={`install-provider-${entry.id}`}
>
{installing ? t("providerCatalog.actions.adding") : t("providerCatalog.actions.add")}
</Button>
</View>
);
}
@@ -208,29 +185,18 @@ const styles = StyleSheet.create((theme) => ({
gap: TILE_GAP,
},
tile: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
padding: theme.spacing[3],
},
tileFull: {
width: "100%",
},
tileHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
singleColumnRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
details: {
gap: theme.spacing[1],
},
detailsInRow: {
flex: 1,
minWidth: 0,
gap: theme.spacing[1],
},
iconFrame: {
flexShrink: 0,
@@ -243,7 +209,6 @@ const styles = StyleSheet.create((theme) => ({
},
addButton: {
flexShrink: 0,
minWidth: 72,
},
name: {
color: theme.colors.foreground,
@@ -246,7 +246,7 @@ const CATALOG_DATA = [
description: "Cloud coding agent through the local hoplite acp bridge",
version: "manual",
iconId: "hoplite",
installLink: "https://hoplite.sh/docs/cli/acp",
installLink: "https://hoplite.sh/docs/cli#install",
command: ["hoplite", "acp"],
},
{
@@ -64,7 +64,7 @@ describe("ACP provider catalog", () => {
expect(findProvider("hoplite")).toMatchObject({
title: "Hoplite",
command: ["hoplite", "acp"],
installLink: "https://hoplite.sh/docs/cli/acp",
installLink: "https://hoplite.sh/docs/cli#install",
});
expect(buildAcpProviderConfigPatch(findProvider("hoplite"), true)).toEqual({
providers: {
@@ -6,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
import { ToastApiProvider } from "@/contexts/toast-api-context";
const { theme, snapshotState, configState, patchConfigMock, openProviderSettingsMock } = vi.hoisted(
() => ({
@@ -281,6 +282,12 @@ vi.mock("@/utils/confirm-dialog", () => ({
import { ProvidersSection } from "./providers-section";
function unexpectedToast(): never {
throw new Error("Provider row interactions should not display a toast");
}
const toastApi = { show: unexpectedToast, copied: unexpectedToast, error: unexpectedToast };
const claudeEntry: ProviderSnapshotEntry = {
provider: "claude",
status: "ready",
@@ -366,7 +373,11 @@ describe("ProvidersSection", () => {
function render(): void {
act(() => {
root?.render(<ProvidersSection serverId="server-1" />);
root?.render(
<ToastApiProvider api={toastApi}>
<ProvidersSection serverId="server-1" />
</ToastApiProvider>,
);
});
}
@@ -11,6 +11,7 @@ import {
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-api-context";
import { settingsStyles } from "@/styles/settings";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useHostFeature } from "@/runtime/host-features";
@@ -326,9 +327,9 @@ export interface ProvidersSectionProps {
export function ProvidersSection({ serverId }: ProvidersSectionProps) {
const { t } = useTranslation();
const toast = useToast();
const isConnected = useHostRuntimeIsConnected(serverId);
const supportsHopliteProvider = useHostFeature(serverId, "hopliteProvider");
const [installError, setInstallError] = useState<string | null>(null);
const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval");
const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
const { config, patchConfig } = useDaemonConfig(serverId);
@@ -413,18 +414,17 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
const handleInstall = useCallback(
async (entry: AcpProviderCatalogItem) => {
if (installingProviderId) return;
setInstallError(null);
setInstallingProviderId(entry.id);
try {
await patchConfig(buildAcpProviderConfigPatch(entry, supportsHopliteProvider));
await refresh([entry.id]);
} catch (error) {
setInstallError(error instanceof Error ? error.message : String(error));
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setInstallingProviderId((current) => (current === entry.id ? null : current));
}
},
[installingProviderId, patchConfig, refresh, supportsHopliteProvider],
[installingProviderId, patchConfig, refresh, supportsHopliteProvider, toast],
);
return (
@@ -480,11 +480,6 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
testID="host-page-add-provider-card"
style={styles.addProviderSection}
>
{installError ? (
<Text accessibilityRole="alert" style={styles.errorText}>
{installError}
</Text>
) : null}
<ProviderCatalogList
serverId={serverId}
installingProviderId={installingProviderId}