From 2d86b94b2fe0210e55c1603eaa686f6cfdf289ce Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 22:45:12 +0700 Subject: [PATCH] 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. --- .../e2e/browser/acp-provider-catalog.spec.ts | 6 +- .../src/components/provider-catalog-list.tsx | 129 +++++++----------- packages/app/src/data/acp-provider-catalog.ts | 2 +- .../hooks/use-acp-provider-catalog.test.ts | 2 +- .../settings/providers-section.test.tsx | 13 +- .../screens/settings/providers-section.tsx | 13 +- 6 files changed, 69 insertions(+), 96 deletions(-) diff --git a/packages/app/e2e/browser/acp-provider-catalog.spec.ts b/packages/app/e2e/browser/acp-provider-catalog.spec.ts index d6ebb8f547..ceca7489e3 100644 --- a/packages/app/e2e/browser/acp-provider-catalog.spec.ts +++ b/packages/app/e2e/browser/acp-provider-catalog.spec.ts @@ -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 }), diff --git a/packages/app/src/components/provider-catalog-list.tsx b/packages/app/src/components/provider-catalog-list.tsx index 748554efde..3e75765919 100644 --- a/packages/app/src/components/provider-catalog-list.tsx +++ b/packages/app/src/components/provider-catalog-list.tsx @@ -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 = ( - - {entry.iconSvg ? ( - - ) : ( - - )} - - ); - const addButton = ( - - ); - const details = ( - - - {entry.title} - - - {/* The catalog is the ACP registry, so every entry it offers is an ACP provider. */} - - - - + () => [settingsStyles.card, styles.tile, width === undefined ? styles.tileFull : { width }], + [width], ); return ( - {isSingleColumn ? ( - - {icon} - {details} - {addButton} + + {entry.iconSvg ? ( + + ) : ( + + )} + + + + {entry.title} + + + {/* The catalog is the ACP registry, so every entry it offers is an ACP provider. */} + + - ) : ( - <> - - {icon} - {addButton} - - {details} - - )} + + ); } @@ -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, diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index 52230d6251..1bd32db53d 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -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"], }, { diff --git a/packages/app/src/hooks/use-acp-provider-catalog.test.ts b/packages/app/src/hooks/use-acp-provider-catalog.test.ts index 5e642e0091..201c17c022 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts @@ -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: { diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index 77aaa4d8ec..0a702ae641 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -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(); + root?.render( + + + , + ); }); } diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 11147b3a3e..72234b7602 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -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(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 ? ( - - {installError} - - ) : null}