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}