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}