- {ProviderIcon ?
: null}
+
{providerLabel}
diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx
index 57a04b1a2f24..70181947bc4a 100644
--- a/apps/web/src/components/chat/ModelPickerContent.tsx
+++ b/apps/web/src/components/chat/ModelPickerContent.tsx
@@ -57,6 +57,8 @@ type ModelPickerItem = {
driverKind: ProviderDriverKind;
instanceDisplayName: string;
instanceAccentColor?: string | undefined;
+ acpRegistryAgentId?: string | undefined;
+ acpRegistryIconUrl?: string | undefined;
continuationGroupKey?: string | undefined;
isLegacy?: boolean | undefined;
isUnavailable?: boolean | undefined;
@@ -387,6 +389,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
driverKind: entry.driverKind,
instanceDisplayName: entry.displayName,
...(entry.accentColor ? { instanceAccentColor: entry.accentColor } : {}),
+ ...(entry.acpRegistryAgentId ? { acpRegistryAgentId: entry.acpRegistryAgentId } : {}),
+ ...(entry.acpRegistryIconUrl ? { acpRegistryIconUrl: entry.acpRegistryIconUrl } : {}),
...(entry.continuationGroupKey
? { continuationGroupKey: entry.continuationGroupKey }
: {}),
@@ -987,6 +991,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
driverKind={model.driverKind}
providerDisplayName={model.instanceDisplayName}
providerAccentColor={model.instanceAccentColor}
+ acpRegistryAgentId={model.acpRegistryAgentId}
+ acpRegistryIconUrl={model.acpRegistryIconUrl}
isFavorite={favoritesSet.has(
providerModelKey(model.instanceId, model.slug),
)}
diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx
index d043c053a073..6d4710205435 100644
--- a/apps/web/src/components/chat/ModelPickerSidebar.tsx
+++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx
@@ -200,6 +200,8 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
driverKind={entry.driverKind}
displayName={entry.displayName}
accentColor={entry.accentColor}
+ acpRegistryAgentId={entry.acpRegistryAgentId}
+ acpRegistryIconUrl={entry.acpRegistryIconUrl}
showBadge={showInstanceBadge}
className="size-6 z-30"
iconClassName="size-5"
diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.test.ts b/apps/web/src/components/chat/ProviderInstanceIcon.test.ts
new file mode 100644
index 000000000000..edc68c599fb0
--- /dev/null
+++ b/apps/web/src/components/chat/ProviderInstanceIcon.test.ts
@@ -0,0 +1,32 @@
+import { ProviderDriverKind } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveProviderInstanceAcpRegistryIconUrl } from "./ProviderInstanceIcon";
+
+describe("resolveProviderInstanceAcpRegistryIconUrl", () => {
+ it("uses allowlisted catalog metadata and rejects untrusted overrides", () => {
+ expect(
+ resolveProviderInstanceAcpRegistryIconUrl({
+ driverKind: ProviderDriverKind.make("acpRegistry"),
+ agentId: "kilo",
+ iconUrl: "https://cdn.agentclientprotocol.com/registry/icons/kilo.svg",
+ }),
+ ).toBe("https://cdn.agentclientprotocol.com/registry/icons/kilo.svg");
+ expect(
+ resolveProviderInstanceAcpRegistryIconUrl({
+ driverKind: ProviderDriverKind.make("acpRegistry"),
+ agentId: "generic-agent",
+ iconUrl: "https://example.com/not-official.svg",
+ }),
+ ).toBe("https://cdn.agentclientprotocol.com/registry/v1/latest/generic-agent.svg");
+ });
+
+ it("does not resolve registry icons for other provider drivers", () => {
+ expect(
+ resolveProviderInstanceAcpRegistryIconUrl({
+ driverKind: ProviderDriverKind.make("codex"),
+ agentId: "kilo",
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx
index 6c36087a1e29..05e2a0a967d5 100644
--- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx
+++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx
@@ -4,13 +4,33 @@ import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider
import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils";
import { cn } from "~/lib/utils";
+import {
+ AcpRegistryAgentIcon,
+ officialAcpRegistryIconUrlForAgentId,
+ resolveOfficialAcpRegistryIconUrl,
+} from "../settings/AcpRegistryIcon";
export { providerInstanceInitials };
+/** Registry agents show their own official glyph, from the catalog or derived from the agent id. */
+export function resolveProviderInstanceAcpRegistryIconUrl(input: {
+ readonly driverKind: ProviderDriverKind;
+ readonly agentId?: string | undefined;
+ readonly iconUrl?: string | undefined;
+}): string | null {
+ if (input.driverKind !== "acpRegistry") return null;
+ return (
+ resolveOfficialAcpRegistryIconUrl(input.iconUrl ?? null) ??
+ officialAcpRegistryIconUrlForAgentId(input.agentId?.trim() || null)
+ );
+}
+
export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: {
driverKind: ProviderDriverKind;
displayName: string;
accentColor?: string | undefined;
+ acpRegistryAgentId?: string | undefined;
+ acpRegistryIconUrl?: string | undefined;
showBadge?: boolean;
badgeContent?: "initials" | "none";
className?: string;
@@ -25,6 +45,7 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: {
? ({ "--provider-accent": props.accentColor } as CSSProperties)
: undefined;
const badgeContent = props.badgeContent ?? "initials";
+ const isAcpRegistry = props.driverKind === "acpRegistry";
return (
- {Icon ? (
+ {isAcpRegistry ? (
+
+ ) : Icon ? (
) : (
diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx
index fbc178965378..b8d5e0cf9d8e 100644
--- a/apps/web/src/components/chat/ProviderModelPicker.tsx
+++ b/apps/web/src/components/chat/ProviderModelPicker.tsx
@@ -245,6 +245,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
driverKind={activeEntry.driverKind}
displayName={activeEntry.displayName}
accentColor={activeEntry.accentColor}
+ acpRegistryAgentId={activeEntry.acpRegistryAgentId}
+ acpRegistryIconUrl={activeEntry.acpRegistryIconUrl}
showBadge={showInstanceBadge}
className="size-4"
iconClassName={cn("size-4", props.activeProviderIconClassName)}
diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts
index db0e5ca222f3..a9ebfebfb9b5 100644
--- a/apps/web/src/components/chat/providerIconUtils.ts
+++ b/apps/web/src/components/chat/providerIconUtils.ts
@@ -1,5 +1,6 @@
import { ProviderDriverKind } from "@t3tools/contracts";
import {
+ ACPRegistryIcon,
AntigravityIcon,
ClaudeAI,
CursorIcon,
@@ -16,6 +17,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial
[ProviderDriverKind.make("cursor")]: CursorIcon,
[ProviderDriverKind.make("grok")]: GrokIcon,
[ProviderDriverKind.make("antigravity")]: AntigravityIcon,
+ [ProviderDriverKind.make("acpRegistry")]: ACPRegistryIcon,
};
export type ModelEsque = {
diff --git a/apps/web/src/components/settings/AcpRegistryIcon.test.ts b/apps/web/src/components/settings/AcpRegistryIcon.test.ts
new file mode 100644
index 000000000000..39e65f644c7d
--- /dev/null
+++ b/apps/web/src/components/settings/AcpRegistryIcon.test.ts
@@ -0,0 +1,206 @@
+import type { ReactElement } from "react";
+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+import { visitElements } from "../../test/reactElementTree";
+import { reactHookHarness as hooks } from "../../test/reactHookHarness";
+
+vi.mock("react", async (importOriginal) => {
+ const actual = await importOriginal();
+ const { reactHookHarness } = await import("../../test/reactHookHarness");
+ return {
+ ...actual,
+ useEffect: (effect: () => void | (() => void)) => effect(),
+ useState: reactHookHarness.useState,
+ useId: () => "acp-icon-test-filter",
+ };
+});
+
+import {
+ AcpRegistryAgentIcon,
+ loadCachedAcpRegistryIcon,
+ officialAcpRegistryIconUrlForAgentId,
+ resolveOfficialAcpRegistryIconUrl,
+} from "./AcpRegistryIcon";
+
+describe("ACP Registry icon cache", () => {
+ const stored = new Map();
+ const match = vi.fn(async (url: string) => stored.get(url)?.clone());
+ const put = vi.fn(async (url: string, response: Response) => {
+ stored.set(url, response.clone());
+ });
+ const fetchIcon = vi.fn();
+
+ beforeEach(() => {
+ hooks.reset();
+ stored.clear();
+ match.mockClear();
+ put.mockClear();
+ fetchIcon.mockReset();
+ vi.stubGlobal("caches", { open: vi.fn(async () => ({ match, put })) });
+ vi.stubGlobal("fetch", fetchIcon);
+ });
+
+ it("single-flights the first fetch and serves later reads from persistent cache", async () => {
+ const url = "https://cdn.agentclientprotocol.com/registry/icons/cache-test.svg";
+ fetchIcon.mockResolvedValue(
+ new Response("", {
+ status: 200,
+ headers: { "content-type": "image/svg+xml" },
+ }),
+ );
+
+ const [first, concurrent] = await Promise.all([
+ loadCachedAcpRegistryIcon(url),
+ loadCachedAcpRegistryIcon(url),
+ ]);
+ const cached = await loadCachedAcpRegistryIcon(url);
+
+ expect(first.type).toBe("image/svg+xml");
+ expect(concurrent.size).toBe(first.size);
+ expect(cached.size).toBe(first.size);
+ expect(fetchIcon).toHaveBeenCalledOnce();
+ expect(put).toHaveBeenCalledOnce();
+ });
+
+ it("rejects oversized image responses before caching", async () => {
+ const url = "https://cdn.agentclientprotocol.com/registry/icons/oversized.svg";
+ fetchIcon.mockResolvedValue(
+ new Response("too large", {
+ status: 200,
+ headers: {
+ "content-length": String(513 * 1_024),
+ "content-type": "image/svg+xml",
+ },
+ }),
+ );
+
+ await expect(loadCachedAcpRegistryIcon(url)).rejects.toThrow("too large");
+ expect(put).not.toHaveBeenCalled();
+ });
+
+ it("falls back to the network when CacheStorage cannot be opened", async () => {
+ const url = "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg";
+ vi.stubGlobal("caches", {
+ open: vi.fn(async () => {
+ throw new Error("CacheStorage unavailable");
+ }),
+ });
+ fetchIcon.mockResolvedValue(
+ new Response("", {
+ status: 200,
+ headers: { "content-type": "image/svg+xml" },
+ }),
+ );
+
+ await expect(loadCachedAcpRegistryIcon(url)).resolves.toMatchObject({
+ type: "image/svg+xml",
+ });
+ expect(fetchIcon).toHaveBeenCalledWith(url, {
+ credentials: "omit",
+ redirect: "error",
+ referrerPolicy: "no-referrer",
+ });
+ });
+
+ it("accepts only credential-free HTTPS URLs on the official CDN", () => {
+ expect(
+ resolveOfficialAcpRegistryIconUrl(
+ "https://cdn.agentclientprotocol.com/registry/icons/gemini.png",
+ ),
+ ).toBe("https://cdn.agentclientprotocol.com/registry/icons/gemini.png");
+ expect(
+ resolveOfficialAcpRegistryIconUrl("http://cdn.agentclientprotocol.com/icon.png"),
+ ).toBeNull();
+ expect(
+ resolveOfficialAcpRegistryIconUrl("https://cdn.agentclientprotocol.com.evil/icon.png"),
+ ).toBeNull();
+ expect(
+ resolveOfficialAcpRegistryIconUrl("https://user@cdn.agentclientprotocol.com/icon.png"),
+ ).toBeNull();
+ expect(
+ resolveOfficialAcpRegistryIconUrl("https://cdn.agentclientprotocol.com:8443/icon.png"),
+ ).toBeNull();
+ expect(officialAcpRegistryIconUrlForAgentId("kilo")).toBe(
+ "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg",
+ );
+ expect(officialAcpRegistryIconUrlForAgentId("../kilo")).toBeNull();
+ });
+
+ it("falls back to the raw allowlisted CDN URL when the validating fetch is blocked", async () => {
+ const url = "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg";
+ // The official CDN serves no CORS headers, so the fetch can reject even
+ // though native
loading works.
+ fetchIcon.mockRejectedValue(new TypeError("Failed to fetch"));
+ hooks.beginRender();
+ AcpRegistryAgentIcon({ icon: url });
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ hooks.beginRender();
+ const tree = AcpRegistryAgentIcon({ icon: url }) as ReactElement>;
+ const image = visitElements(tree, (element) => element.type === "img");
+ expect(image?.props).toMatchObject({ src: url, referrerPolicy: "no-referrer" });
+ expect(
+ visitElements(tree, (element) => element.props["data-slot"] === "acp-icon-fallback"),
+ ).not.toBeNull();
+ });
+
+ it("renders only the validated blob object URL and keeps the fallback until load", async () => {
+ const url = "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg";
+ const objectUrl = "blob:t3/kilo-icon";
+ Object.assign(URL, {
+ createObjectURL: vi.fn(() => objectUrl),
+ revokeObjectURL: vi.fn(),
+ });
+ fetchIcon.mockResolvedValue(
+ new Response("", {
+ status: 200,
+ headers: { "content-type": "image/svg+xml" },
+ }),
+ );
+ hooks.beginRender();
+ const validating = AcpRegistryAgentIcon({ icon: url }) as ReactElement>;
+
+ // The raw CDN URL is never rendered; only the fallback shows while the
+ // blob is fetched and validated.
+ expect(visitElements(validating, (element) => element.type === "img")).toBeNull();
+ expect(
+ visitElements(validating, (element) => element.props["data-slot"] === "acp-icon-fallback"),
+ ).not.toBeNull();
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ hooks.beginRender();
+ const loading = AcpRegistryAgentIcon({ icon: url }) as ReactElement>;
+ const loadingImage = visitElements(loading, (element) => element.type === "img");
+
+ expect(loadingImage?.props).toMatchObject({
+ src: objectUrl,
+ alt: "",
+ decoding: "async",
+ referrerPolicy: "no-referrer",
+ });
+ expect(loadingImage?.props.className).not.toContain("dark:invert");
+ expect(loadingImage?.props.className).toContain("invisible");
+ expect(
+ visitElements(loading, (element) => element.props["data-slot"] === "acp-icon-fallback"),
+ ).not.toBeNull();
+
+ (loadingImage?.props.onLoad as (() => void) | undefined)?.();
+ hooks.beginRender();
+ const loaded = AcpRegistryAgentIcon({ icon: url }) as ReactElement>;
+ expect(
+ visitElements(loaded, (element) => element.type === "img")?.props.className,
+ ).not.toContain("invisible");
+ expect(
+ visitElements(loaded, (element) => element.props["data-slot"] === "acp-icon-fallback"),
+ ).toBeNull();
+
+ const loadedImage = visitElements(loaded, (element) => element.type === "img");
+ (loadedImage?.props.onError as (() => void) | undefined)?.();
+ hooks.beginRender();
+ const failed = AcpRegistryAgentIcon({ icon: url }) as ReactElement>;
+ expect(visitElements(failed, (element) => element.type === "img")).toBeNull();
+ expect(
+ visitElements(failed, (element) => element.props["data-slot"] === "acp-icon-fallback"),
+ ).not.toBeNull();
+ });
+});
diff --git a/apps/web/src/components/settings/AcpRegistryIcon.tsx b/apps/web/src/components/settings/AcpRegistryIcon.tsx
new file mode 100644
index 000000000000..62553177d14e
--- /dev/null
+++ b/apps/web/src/components/settings/AcpRegistryIcon.tsx
@@ -0,0 +1,170 @@
+import { useEffect, useId, useState } from "react";
+import {
+ resolveOfficialAcpRegistryIconUrl,
+ officialAcpRegistryIconUrlForAgentId,
+} from "@t3tools/contracts";
+
+import { cn } from "../../lib/utils";
+import { ACPRegistryIcon } from "../Icons";
+
+const ACP_REGISTRY_ICON_CACHE = "t3-acp-registry-icons-v1";
+const MAX_ICON_BYTES = 512 * 1_024;
+const inFlightIcons = new Map>();
+
+export { officialAcpRegistryIconUrlForAgentId, resolveOfficialAcpRegistryIconUrl };
+
+async function checkedIconBlob(response: Response): Promise {
+ if (!response.ok || response.redirected) throw new Error("ACP registry icon request failed.");
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim() ?? "";
+ if (!contentType.startsWith("image/")) throw new Error("ACP registry icon was not an image.");
+ const contentLength = Number(response.headers.get("content-length"));
+ if (Number.isFinite(contentLength) && contentLength > MAX_ICON_BYTES) {
+ throw new Error("ACP registry icon was too large.");
+ }
+ const bytes = await response.arrayBuffer();
+ if (bytes.byteLength > MAX_ICON_BYTES) throw new Error("ACP registry icon was too large.");
+ return new Blob([bytes], { type: contentType });
+}
+
+/** Fetches an official icon once, then serves it from persistent browser cache. */
+export function loadCachedAcpRegistryIcon(iconUrl: string): Promise {
+ if (resolveOfficialAcpRegistryIconUrl(iconUrl) !== iconUrl) {
+ return Promise.reject(new Error("ACP registry icon URL was not allowed."));
+ }
+ const pending = inFlightIcons.get(iconUrl);
+ if (pending) return pending;
+ const load = (async () => {
+ let cache: Cache | null = null;
+ if (typeof caches !== "undefined") {
+ try {
+ cache = await caches.open(ACP_REGISTRY_ICON_CACHE);
+ } catch {
+ // Some privacy modes expose CacheStorage but reject access. Icons can
+ // still load normally from the allowlisted registry CDN.
+ }
+ }
+ if (cache !== null) {
+ try {
+ const cached = await cache.match(iconUrl);
+ if (cached) return await checkedIconBlob(cached);
+ } catch {
+ // Ignore unreadable or invalid cache entries and repair from network.
+ }
+ }
+
+ const response = await fetch(iconUrl, {
+ credentials: "omit",
+ redirect: "error",
+ referrerPolicy: "no-referrer",
+ });
+ const clone = response.clone();
+ const blob = await checkedIconBlob(response);
+ await cache?.put(iconUrl, clone).catch(() => undefined);
+ return blob;
+ })();
+ inFlightIcons.set(iconUrl, load);
+ void load.then(
+ () => inFlightIcons.delete(iconUrl),
+ () => inFlightIcons.delete(iconUrl),
+ );
+ return load;
+}
+
+export function AcpRegistryAgentIcon({
+ icon,
+ className,
+ fallbackClassName,
+}: {
+ readonly icon: string | null;
+ readonly className?: string;
+ readonly fallbackClassName?: string;
+}) {
+ const colorFilterId = useId();
+ const iconUrl = resolveOfficialAcpRegistryIconUrl(icon);
+ const [image, setImage] = useState<{
+ readonly iconUrl: string;
+ readonly source: string;
+ readonly status: "loading" | "loaded" | "failed";
+ } | null>(null);
+ const currentImage = image?.iconUrl === iconUrl ? image : null;
+ // Render only after the load pipeline resolves: the validated blob when the
+ // fetch succeeds, or the raw allowlisted CDN URL when it fails (the official
+ // CDN serves no CORS headers, so the validating fetch is often blocked in
+ // cross-origin browser contexts while native
loading still works).
+ const source = currentImage?.source ?? null;
+ const status = currentImage?.status ?? "loading";
+
+ useEffect(() => {
+ if (iconUrl === null) return;
+ let active = true;
+ let objectUrl: string | null = null;
+ void loadCachedAcpRegistryIcon(iconUrl)
+ .then((blob) => {
+ if (!active) return;
+ objectUrl = URL.createObjectURL(blob);
+ setImage({ iconUrl, source: objectUrl, status: "loading" });
+ })
+ .catch(() => {
+ if (!active) return;
+ setImage((current) =>
+ current?.iconUrl === iconUrl ? current : { iconUrl, source: iconUrl, status: "loading" },
+ );
+ });
+ return () => {
+ active = false;
+ if (objectUrl !== null) URL.revokeObjectURL(objectUrl);
+ };
+ }, [iconUrl]);
+
+ return (
+
+ {/* Registry glyphs are monochrome. Recolor their alpha rather than
+ inverting RGB values; external images cannot inherit currentColor.
+ A local SVG filter also works when CDN CORS prevents CSS masks. */}
+
+ {status !== "loaded" ? (
+
+ ) : null}
+ {source !== null && status !== "failed" ? (
+
{
+ if (iconUrl === null) return;
+ setImage((current) =>
+ current?.iconUrl === iconUrl && current.source !== source
+ ? current
+ : { iconUrl, source, status: "failed" },
+ );
+ }}
+ onLoad={() => {
+ if (iconUrl !== null) setImage({ iconUrl, source, status: "loaded" });
+ }}
+ />
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/settings/AcpRegistrySearchStep.test.tsx b/apps/web/src/components/settings/AcpRegistrySearchStep.test.tsx
new file mode 100644
index 000000000000..06d5704c415c
--- /dev/null
+++ b/apps/web/src/components/settings/AcpRegistrySearchStep.test.tsx
@@ -0,0 +1,328 @@
+import type { ReactElement } from "react";
+import { EnvironmentId, ProviderDriverKind, type AcpRegistrySearchAgent } from "@t3tools/contracts";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+import { visitElements } from "../../test/reactElementTree";
+import { reactHookHarness as hooks } from "../../test/reactHookHarness";
+
+const atoms = vi.hoisted(() => ({
+ search: Symbol("acp-search"),
+ prepare: Symbol("acp-prepare"),
+}));
+
+const state = vi.hoisted(() => ({
+ result: null as { readonly agents: ReadonlyArray } | null,
+ error: null as string | null,
+ isPending: false,
+ refresh: vi.fn(),
+ search: vi.fn(() => atoms.search),
+ prepare: vi.fn(),
+}));
+
+const lifecycle = vi.hoisted(() => ({
+ cleanups: [] as Array<() => void>,
+}));
+
+vi.mock("react", async (importOriginal) => {
+ const actual = await importOriginal();
+ const { reactHookHarness } = await import("../../test/reactHookHarness");
+ return {
+ ...actual,
+ useEffect: (effect: () => void | (() => void)) => {
+ const cleanup = effect();
+ if (cleanup) lifecycle.cleanups.push(cleanup);
+ },
+ useRef: reactHookHarness.useRef,
+ useLayoutEffect: (effect: () => void | (() => void)) => {
+ const cleanup = effect();
+ if (cleanup) lifecycle.cleanups.push(cleanup);
+ },
+ useState: reactHookHarness.useState,
+ };
+});
+
+vi.mock("react/compiler-runtime", async () => {
+ const { reactHookHarness } = await import("../../test/reactHookHarness");
+ return { c: reactHookHarness.useMemoCache };
+});
+
+vi.mock("../../state/server", () => ({
+ serverEnvironment: {
+ searchAcpRegistry: state.search,
+ prepareAcpRegistryAgent: atoms.prepare,
+ },
+}));
+
+vi.mock("../../state/query", () => ({
+ useEnvironmentQuery: () => ({
+ data: state.result,
+ error: state.error,
+ isPending: state.isPending,
+ refresh: state.refresh,
+ }),
+}));
+
+vi.mock("../../state/use-atom-command", () => ({
+ useAtomCommand: () => state.prepare,
+}));
+
+vi.mock("@t3tools/client-runtime/state/runtime", () => ({
+ isAtomCommandInterrupted: () => false,
+ squashAtomCommandFailure: () => new Error("Prepare failed."),
+}));
+
+import { AcpRegistrySearchStep } from "./AcpRegistrySearchStep";
+
+const environmentId = EnvironmentId.make("remote-device");
+const gemini: AcpRegistrySearchAgent = {
+ id: "gemini",
+ name: "Gemini CLI",
+ version: "1.2.3",
+ description: "Google's agent",
+ authors: ["Google ", "Contributor"],
+ license: "Apache-2.0",
+ website: "https://example.com/docs",
+ repository: "https://example.com/source",
+ icon: "https://example.com/icon.png",
+ distribution: "npx",
+ integrity: "registry",
+};
+
+function render(options?: {
+ readonly configured?: boolean;
+ readonly onPrepared?: (agent: AcpRegistrySearchAgent) => void;
+}): ReactElement> {
+ hooks.beginRender();
+ return AcpRegistrySearchStep({
+ environmentId,
+ providerInstances: options?.configured
+ ? {
+ acpRegistry_gemini: {
+ driver: ProviderDriverKind.make("acpRegistry"),
+ config: { agentId: "gemini" },
+ },
+ }
+ : {},
+ onPrepared: options?.onPrepared ?? vi.fn(),
+ onManualConfiguration: vi.fn(),
+ }) as ReactElement>;
+}
+
+function findByAriaLabel(
+ tree: ReactElement>,
+ label: string,
+): ReactElement> {
+ const found = visitElements(tree, (element) => element.props["aria-label"] === label);
+ expect(found).not.toBeNull();
+ return found!;
+}
+
+describe("AcpRegistrySearchStep", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ hooks.reset();
+ state.result = null;
+ state.error = null;
+ state.isPending = false;
+ state.refresh.mockReset();
+ state.search.mockClear();
+ state.prepare.mockReset().mockResolvedValue({
+ _tag: "Success",
+ value: { agentId: "gemini", version: "1.2.3", distribution: "npx", prepared: true },
+ });
+ lifecycle.cleanups = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => new Promise(() => {})),
+ );
+ });
+ afterEach(() => {
+ for (const cleanup of lifecycle.cleanups) cleanup();
+ vi.useRealTimers();
+ });
+
+ it("loads the catalog immediately and searches only after typing settles", () => {
+ const initial = render();
+ expect(state.search).toHaveBeenLastCalledWith({
+ environmentId,
+ input: { query: "" },
+ });
+
+ const input = findByAriaLabel(initial, "Search ACP Registry");
+ expect(input.props.size).toBe("sm");
+ (input.props.onChange as ((event: { currentTarget: { value: string } }) => void) | undefined)?.(
+ { currentTarget: { value: " Gemini " } },
+ );
+ vi.advanceTimersByTime(200);
+ render();
+ expect(state.search).toHaveBeenLastCalledWith({ environmentId, input: { query: "" } });
+ (input.props.onChange as ((event: { currentTarget: { value: string } }) => void) | undefined)?.(
+ {
+ currentTarget: { value: " Gemini CLI " },
+ },
+ );
+ vi.advanceTimersByTime(299);
+ render();
+ expect(state.search).toHaveBeenLastCalledWith({ environmentId, input: { query: "" } });
+ vi.advanceTimersByTime(1);
+ render();
+ expect(state.search).toHaveBeenLastCalledWith({
+ environmentId,
+ input: { query: "Gemini CLI" },
+ });
+ (input.props.onChange as ((event: { currentTarget: { value: string } }) => void) | undefined)?.(
+ {
+ currentTarget: { value: "" },
+ },
+ );
+ vi.advanceTimersByTime(300);
+ render();
+ expect(state.search).toHaveBeenLastCalledWith({ environmentId, input: { query: "" } });
+ });
+
+ it("renders deterministic loading, error, and empty states", () => {
+ state.isPending = true;
+ expect(
+ visitElements(render(), (element) => element.props.children === "Searching the registry..."),
+ ).not.toBeNull();
+
+ state.isPending = false;
+ state.error = "Registry unavailable.";
+ expect(
+ visitElements(render(), (element) => element.props.children === "Registry unavailable."),
+ ).not.toBeNull();
+
+ state.error = null;
+ state.result = { agents: [] };
+ expect(
+ visitElements(render(), (element) => element.props.children === "No compatible agents found"),
+ ).not.toBeNull();
+ });
+
+ it("keeps same-query refreshes visible and announced while retaining results", () => {
+ const first = render();
+ const input = findByAriaLabel(first, "Search ACP Registry");
+ (input.props.onChange as ((event: { currentTarget: { value: string } }) => void) | undefined)?.(
+ {
+ currentTarget: { value: "Codex" },
+ },
+ );
+ const draft = render();
+ const searchForm = visitElements(draft, (element) => element.type === "form");
+ (searchForm?.props.onSubmit as ((event: { preventDefault: () => void }) => void) | undefined)?.(
+ {
+ preventDefault: vi.fn(),
+ },
+ );
+
+ state.result = { agents: [gemini] };
+ const resultTree = render();
+ expect(state.search).toHaveBeenCalledWith({
+ environmentId,
+ input: { query: "Codex" },
+ });
+ const form = visitElements(resultTree, (element) => element.type === "form");
+ (form?.props.onSubmit as ((event: { preventDefault: () => void }) => void) | undefined)?.({
+ preventDefault: vi.fn(),
+ });
+ expect(state.refresh).toHaveBeenCalledOnce();
+
+ state.isPending = true;
+ const refreshingTree = render();
+ expect(
+ visitElements(
+ refreshingTree,
+ (element) =>
+ element.props.role === "status" &&
+ element.props.children === "Refreshing ACP Registry results.",
+ ),
+ ).not.toBeNull();
+ expect(findByAriaLabel(refreshingTree, "Add Gemini CLI")).not.toBeNull();
+ });
+
+ it("prepares a result before handing it back to the wizard", async () => {
+ state.prepare.mockResolvedValueOnce({
+ _tag: "Success",
+ value: { agentId: "gemini", version: "2.0.0", distribution: "binary", prepared: true },
+ });
+ state.result = { agents: [gemini] };
+ const onPrepared = vi.fn();
+ const tree = render({ onPrepared });
+
+ const add = findByAriaLabel(tree, "Add Gemini CLI");
+ (add.props.onClick as (() => void) | undefined)?.();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(state.prepare).toHaveBeenCalledWith({
+ environmentId,
+ input: { agentId: "gemini" },
+ });
+ expect(onPrepared).toHaveBeenCalledWith({
+ ...gemini,
+ version: "2.0.0",
+ distribution: "binary",
+ });
+ });
+
+ it("ignores a stale prepare completion", async () => {
+ let resolveFirst!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void;
+ let resolveSecond!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void;
+ state.prepare
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveFirst = resolve;
+ }),
+ )
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveSecond = resolve;
+ }),
+ );
+ state.result = { agents: [gemini] };
+ const onPrepared = vi.fn();
+ const tree = render({ onPrepared });
+ const add = findByAriaLabel(tree, "Add Gemini CLI");
+
+ (add.props.onClick as (() => void) | undefined)?.();
+ (add.props.onClick as (() => void) | undefined)?.();
+ resolveFirst({ _tag: "Success", value: {} });
+ await Promise.resolve();
+ expect(onPrepared).not.toHaveBeenCalled();
+
+ resolveSecond({ _tag: "Success", value: {} });
+ await Promise.resolve();
+ expect(onPrepared).toHaveBeenCalledOnce();
+ });
+
+ it("ignores prepare completion after unmount", async () => {
+ let resolvePrepare!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void;
+ state.prepare.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolvePrepare = resolve;
+ }),
+ );
+ state.result = { agents: [gemini] };
+ const onPrepared = vi.fn();
+ const tree = render({ onPrepared });
+
+ const add = findByAriaLabel(tree, "Add Gemini CLI");
+ (add.props.onClick as (() => void) | undefined)?.();
+ for (const cleanup of lifecycle.cleanups) cleanup();
+ resolvePrepare({ _tag: "Success", value: {} });
+ await Promise.resolve();
+
+ expect(onPrepared).not.toHaveBeenCalled();
+ });
+
+ it("renders existing registry configuration as already added", () => {
+ state.result = { agents: [gemini] };
+ const tree = render({ configured: true });
+ const added = findByAriaLabel(tree, "Already added Gemini CLI");
+
+ expect(added.props.disabled).toBe(true);
+ });
+});
diff --git a/apps/web/src/components/settings/AcpRegistrySearchStep.tsx b/apps/web/src/components/settings/AcpRegistrySearchStep.tsx
new file mode 100644
index 000000000000..2084c035d671
--- /dev/null
+++ b/apps/web/src/components/settings/AcpRegistrySearchStep.tsx
@@ -0,0 +1,279 @@
+import {
+ isAtomCommandInterrupted,
+ squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
+import type {
+ AcpRegistryPrepareResult,
+ AcpRegistrySearchAgent,
+ EnvironmentId,
+ ProviderInstanceConfig,
+} from "@t3tools/contracts";
+import { ExternalLinkIcon, SearchIcon } from "lucide-react";
+import { type FormEvent, useEffect, useLayoutEffect, useRef, useState } from "react";
+
+import { serverEnvironment } from "../../state/server";
+import { useEnvironmentQuery } from "../../state/query";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { Alert, AlertDescription } from "../ui/alert";
+import { Button } from "../ui/button";
+import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
+import { ScrollArea } from "../ui/scroll-area";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+import { isConfiguredAcpRegistryAgent } from "./AddProviderInstanceDialog.logic";
+import { ProviderDriverKind } from "@t3tools/contracts";
+import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error && error.message.trim()
+ ? error.message
+ : "The ACP could not be prepared.";
+}
+
+interface AcpRegistrySearchStepProps {
+ readonly environmentId: EnvironmentId;
+ readonly providerInstances: Readonly>;
+ readonly onPrepared: (agent: AcpRegistrySearchAgent) => void;
+ readonly onManualConfiguration: () => void;
+ readonly onLoadingChange?: (loading: boolean) => void;
+ readonly onPreparingChange?: (preparing: boolean) => void;
+}
+
+function applyAcpRegistryPrepareResult(
+ agent: AcpRegistrySearchAgent,
+ prepared: AcpRegistryPrepareResult,
+): AcpRegistrySearchAgent {
+ return {
+ ...agent,
+ id: prepared.agentId,
+ version: prepared.version,
+ distribution: prepared.distribution,
+ };
+}
+
+export function AcpRegistrySearchStep({
+ environmentId,
+ providerInstances,
+ onPrepared,
+ onManualConfiguration,
+ onLoadingChange,
+ onPreparingChange,
+}: AcpRegistrySearchStepProps) {
+ const [query, setQuery] = useState("");
+ // An empty registry query is the compact compatible catalog. Start there so
+ // entering this step is useful before the user knows what to search for.
+ const [submittedQuery, setSubmittedQuery] = useState("");
+ const [preparingId, setPreparingId] = useState(null);
+ const [prepareError, setPrepareError] = useState(null);
+ const prepareGeneration = useRef(0);
+ const searchTimer = useRef | null>(null);
+ const search = useEnvironmentQuery(
+ serverEnvironment.searchAcpRegistry({
+ environmentId,
+ input: { query: submittedQuery },
+ }),
+ );
+ const prepareAgent = useAtomCommand(serverEnvironment.prepareAcpRegistryAgent, {
+ reportFailure: false,
+ });
+
+ useEffect(
+ () => () => {
+ prepareGeneration.current += 1;
+ if (searchTimer.current !== null) clearTimeout(searchTimer.current);
+ onPreparingChange?.(false);
+ },
+ [onPreparingChange],
+ );
+
+ const submitSearch = (nextQuery: string) => {
+ if (searchTimer.current !== null) clearTimeout(searchTimer.current);
+ const trimmed = nextQuery.trim();
+ setQuery(trimmed);
+ setPrepareError(null);
+ if (trimmed === submittedQuery) {
+ search.refresh();
+ } else {
+ setSubmittedQuery(trimmed);
+ }
+ };
+
+ const handleSearch = (event: FormEvent) => {
+ event.preventDefault();
+ submitSearch(query);
+ };
+
+ const handlePrepare = async (agent: AcpRegistrySearchAgent) => {
+ const generation = ++prepareGeneration.current;
+ setPrepareError(null);
+ setPreparingId(agent.id);
+ onPreparingChange?.(true);
+ const result = await prepareAgent({ environmentId, input: { agentId: agent.id } });
+ if (prepareGeneration.current !== generation) return;
+ setPreparingId(null);
+ onPreparingChange?.(false);
+ if (result._tag === "Success") {
+ onPrepared(applyAcpRegistryPrepareResult(agent, result.value));
+ return;
+ }
+ if (!isAtomCommandInterrupted(result)) {
+ setPrepareError(errorMessage(squashAtomCommandFailure(result)));
+ }
+ };
+
+ const results = search.data?.agents ?? null;
+ const isInitialSearch = search.isPending && results === null;
+ const isRefreshing = search.isPending && results !== null;
+ const resultCount = results?.length ?? 0;
+
+ useLayoutEffect(() => {
+ onLoadingChange?.(search.isPending);
+ return () => onLoadingChange?.(false);
+ }, [onLoadingChange, search.isPending]);
+
+ return (
+
+
+ Choose an agent
+
+
+
+
+
+ {isInitialSearch
+ ? "Searching the ACP Registry."
+ : isRefreshing
+ ? "Refreshing ACP Registry results."
+ : results
+ ? `${resultCount} compatible ${resultCount === 1 ? "agent" : "agents"} found.`
+ : ""}
+
+
+ {search.error || prepareError ? (
+
+ {prepareError ?? search.error}
+
+ ) : null}
+
+ {isInitialSearch ? (
+
+ Searching the registry...
+
+ ) : null}
+
+ {results ? (
+ results.length === 0 ? (
+
+
No compatible agents found
+
Try a broader search.
+
+ ) : (
+
+ {/* The overlay scrollbar takes no layout space, so the rows
+ reserve its lane explicitly. */}
+
+ {results.map((agent) => {
+ const alreadyAdded = isConfiguredAcpRegistryAgent(providerInstances, agent.id);
+ const isPreparing = preparingId === agent.id;
+ const progressLabel = agent.distribution === "binary" ? "Downloading" : "Preparing";
+ return (
+
+
+
+
+
+
+
+ {agent.name}
+
+
+ {agent.description ? (
+
+ {agent.description}
+
+ ) : null}
+
+
+
+ {agent.website || agent.repository ? (
+
+
+ }
+ >
+
+
+ }
+ />
+ About {agent.name}
+
+ ) : null}
+
+
+
+
+ );
+ })}
+
+
+ )
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx
index 3c502c624ddd..b59b070f18a2 100644
--- a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx
+++ b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx
@@ -28,6 +28,10 @@ vi.mock("../../hooks/useSettings", () => ({
useUpdateEnvironmentSettings: settingsHooks.update,
}));
+vi.mock("../../state/use-atom-command", () => ({
+ useAtomCommand: () => vi.fn(),
+}));
+
import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
const remoteEnvironmentId = EnvironmentId.make("remote-device");
diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts
index fdffa9a190ea..63a803d2d908 100644
--- a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts
+++ b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts
@@ -3,8 +3,73 @@ export type WizardNavigation =
| { readonly kind: "blocked"; readonly step: number; readonly error: string };
const IDENTITY_STEP = 1;
+const ACP_REGISTRY_IDENTITY_STEP = 1;
export const ADD_PROVIDER_WIZARD_STEPS = ["Driver", "Identity", "Config"] as const;
+export const ACP_REGISTRY_WIZARD_STEPS = ["Driver", "Identity", "Sign in"] as const;
+
+export interface ProviderIdentityDraft {
+ readonly label: string;
+ readonly accentColor: string;
+ readonly instanceIdOverride: string | null;
+}
+
+const EMPTY_PROVIDER_IDENTITY_DRAFT: ProviderIdentityDraft = {
+ label: "",
+ accentColor: "",
+ instanceIdOverride: null,
+};
+
+export function getProviderIdentityDraft(
+ drafts: Readonly>,
+ driver: string,
+): ProviderIdentityDraft {
+ return drafts[driver] ?? EMPTY_PROVIDER_IDENTITY_DRAFT;
+}
+
+/** Identity drafts are kept per driver so a registry prefill never leaks into another driver. */
+export function updateProviderIdentityDraft(
+ drafts: Readonly>,
+ driver: string,
+ update: Partial,
+): Record {
+ return {
+ ...drafts,
+ [driver]: { ...getProviderIdentityDraft(drafts, driver), ...update },
+ };
+}
+
+/** Appends `_2`, `_3`, ... until the id is free, keeping it within the 64-char slug cap. */
+export function deriveAvailableInstanceId(
+ derive: (label: string) => string,
+ label: string,
+ existing: ReadonlySet,
+): string {
+ const base = derive(label);
+ if (!base || !existing.has(base)) return base;
+
+ for (let suffix = 2; ; suffix += 1) {
+ const suffixText = `_${suffix}`;
+ const candidate = `${base.slice(0, 64 - suffixText.length)}${suffixText}`;
+ if (!existing.has(candidate)) return candidate;
+ }
+}
+
+export function isConfiguredAcpRegistryAgent(
+ instances: Readonly>,
+ agentId: string,
+): boolean {
+ return Object.values(instances).some((instance) => {
+ if (
+ instance.driver !== "acpRegistry" ||
+ !instance.config ||
+ typeof instance.config !== "object"
+ ) {
+ return false;
+ }
+ return (instance.config as Record).agentId === agentId;
+ });
+}
/**
* Resolve navigation within the add-provider wizard.
@@ -12,25 +77,59 @@ export const ADD_PROVIDER_WIZARD_STEPS = ["Driver", "Identity", "Config"] as con
* Moving forward past Identity requires a valid instance id, whether the user
* advances one step at a time or skips directly to Config from a step header.
* A blocked skip lands on Identity so its existing inline validation is
- * visible. Backward navigation is always preserved.
+ * visible. A prerequisite step (the ACP Registry agent pick) blocks the same
+ * way. Backward navigation is always preserved.
*/
export function resolveWizardNavigation(
currentStep: number,
requestedStep: number,
stepCount: number,
- validation: { readonly instanceIdError: string | null },
+ validation: {
+ readonly instanceIdError: string | null;
+ readonly identityStep?: number;
+ readonly prerequisite?: {
+ readonly step: number;
+ readonly error: string | null;
+ };
+ },
): WizardNavigation {
const lastStep = Math.max(0, stepCount - 1);
const targetStep = Math.max(0, Math.min(lastStep, requestedStep));
- const movesForwardPastIdentity = currentStep <= IDENTITY_STEP && targetStep > IDENTITY_STEP;
+ const identityStep = validation.identityStep ?? IDENTITY_STEP;
+ const prerequisite = validation.prerequisite;
+
+ if (prerequisite?.error && currentStep <= prerequisite.step && targetStep > prerequisite.step) {
+ return {
+ kind: "blocked",
+ step: Math.min(prerequisite.step, lastStep),
+ error: prerequisite.error,
+ };
+ }
+
+ const movesForwardPastIdentity = currentStep <= identityStep && targetStep > identityStep;
if (movesForwardPastIdentity && validation.instanceIdError !== null) {
return {
kind: "blocked",
- step: Math.min(IDENTITY_STEP, lastStep),
+ step: Math.min(identityStep, lastStep),
error: validation.instanceIdError,
};
}
return { kind: "navigate", step: targetStep };
}
+
+export function resolveAcpRegistryWizardNavigation(
+ currentStep: number,
+ requestedStep: number,
+ validation: {
+ readonly instanceIdError: string | null;
+ readonly selectionError: string | null;
+ },
+): WizardNavigation {
+ return resolveWizardNavigation(currentStep, requestedStep, ACP_REGISTRY_WIZARD_STEPS.length, {
+ instanceIdError: validation.instanceIdError,
+ identityStep: ACP_REGISTRY_IDENTITY_STEP,
+ prerequisite: { step: 0, error: validation.selectionError },
+ });
+}
diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts
index 594d2e4537e3..71cac1f1c3a0 100644
--- a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts
+++ b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts
@@ -1,6 +1,13 @@
import { describe, expect, it } from "vite-plus/test";
-import { resolveWizardNavigation } from "./AddProviderInstanceDialog.logic";
+import {
+ deriveAvailableInstanceId,
+ getProviderIdentityDraft,
+ isConfiguredAcpRegistryAgent,
+ resolveAcpRegistryWizardNavigation,
+ resolveWizardNavigation,
+ updateProviderIdentityDraft,
+} from "./AddProviderInstanceDialog.logic";
describe("resolveWizardNavigation", () => {
const invalidId = { instanceIdError: "Instance ID is required." };
@@ -42,3 +49,69 @@ describe("resolveWizardNavigation", () => {
expect(resolveWizardNavigation(0, -1, 3, invalidId)).toEqual({ kind: "navigate", step: 0 });
});
});
+
+describe("ACP Registry wizard", () => {
+ it("requires a prepared result or valid manual configuration before Identity", () => {
+ expect(
+ resolveAcpRegistryWizardNavigation(0, 1, {
+ instanceIdError: null,
+ selectionError: "Select an ACP or configure one manually.",
+ }),
+ ).toEqual({
+ kind: "blocked",
+ step: 0,
+ error: "Select an ACP or configure one manually.",
+ });
+
+ expect(
+ resolveAcpRegistryWizardNavigation(0, 1, {
+ instanceIdError: null,
+ selectionError: null,
+ }),
+ ).toEqual({ kind: "navigate", step: 1 });
+ });
+
+ it("derives a collision-free instance id without exceeding the slug limit", () => {
+ const derive = (label: string) => `acpRegistry_${label}`;
+ const existing = new Set(["acpRegistry_gemini", "acpRegistry_gemini_2"]);
+
+ expect(deriveAvailableInstanceId(derive, "gemini", existing)).toBe("acpRegistry_gemini_3");
+
+ const longBase = `acpRegistry_${"a".repeat(48)}`;
+ expect(deriveAvailableInstanceId(() => longBase, "ignored", new Set([longBase]))).toHaveLength(
+ 62,
+ );
+ });
+
+ it("only marks matching ACP Registry instances as already added", () => {
+ const instances = {
+ codex: { driver: "codex", config: { agentId: "gemini" } },
+ registry: { driver: "acpRegistry", config: { agentId: "gemini" } },
+ };
+
+ expect(isConfiguredAcpRegistryAgent(instances, "gemini")).toBe(true);
+ expect(isConfiguredAcpRegistryAgent(instances, "codex")).toBe(false);
+ });
+
+ it("keeps registry-prefilled identity separate from other drivers", () => {
+ const registryDrafts = updateProviderIdentityDraft({}, "acpRegistry", {
+ label: "Gemini CLI",
+ instanceIdOverride: "acpRegistry_gemini_cli",
+ });
+
+ expect(getProviderIdentityDraft(registryDrafts, "codex")).toEqual({
+ label: "",
+ accentColor: "",
+ instanceIdOverride: null,
+ });
+
+ const drafts = updateProviderIdentityDraft(registryDrafts, "codex", {
+ label: "Work",
+ instanceIdOverride: "codex_work",
+ });
+ expect(getProviderIdentityDraft(drafts, "acpRegistry")).toMatchObject({
+ label: "Gemini CLI",
+ instanceIdOverride: "acpRegistry_gemini_cli",
+ });
+ });
+});
diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx
index 84d0a966880d..00c547454719 100644
--- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx
+++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx
@@ -3,9 +3,11 @@
import { Radio as RadioPrimitive } from "@base-ui/react/radio";
import { CheckIcon } from "lucide-react";
import { useMemo, useState } from "react";
+import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import {
ProviderInstanceId,
ProviderDriverKind,
+ type AcpRegistrySearchAgent,
type EnvironmentId,
type ProviderInstanceConfig,
} from "@t3tools/contracts";
@@ -13,8 +15,10 @@ import {
import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings";
import { cn } from "../../lib/utils";
import { normalizeProviderAccentColor } from "../../providerInstances";
+import { serverEnvironment } from "../../state/server";
+import { useAtomCommand } from "../../state/use-atom-command";
import { Button } from "../ui/button";
-import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";
+import { Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";
import { Dialog } from "../ui/dialog";
import { Badge } from "../ui/badge";
import { Input } from "../ui/input";
@@ -25,11 +29,20 @@ import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm";
import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wizard";
import {
+ ACP_REGISTRY_WIZARD_STEPS,
ADD_PROVIDER_WIZARD_STEPS,
+ deriveAvailableInstanceId,
+ getProviderIdentityDraft,
+ resolveAcpRegistryWizardNavigation,
resolveWizardNavigation,
+ updateProviderIdentityDraft,
+ type ProviderIdentityDraft,
type WizardNavigation,
} from "./AddProviderInstanceDialog.logic";
import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps";
+import { AcpRegistrySearchStep } from "./AcpRegistrySearchStep";
+import { resolveOfficialAcpRegistryIconUrl } from "./AcpRegistryIcon";
+import { ProviderWizardAuthenticationStep } from "./ProviderWizardAuthenticationStep";
const PROVIDER_ACCENT_SWATCHES = [
"#2563eb",
@@ -63,6 +76,7 @@ function deriveInstanceId(driver: ProviderDriverKind, label: string): string {
const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");
+const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry");
const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!;
const EMPTY_CONFIG_DRAFT: Record = {};
interface ComingSoonDriverOption {
@@ -82,11 +96,6 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [
label: "Gemini",
icon: Gemini,
},
- {
- value: ProviderDriverKind.make("acpRegistry"),
- label: "ACP Registry",
- icon: ACPRegistryIcon,
- },
{
value: ProviderDriverKind.make("piAgent"),
label: "Pi Agent",
@@ -114,6 +123,7 @@ interface AddProviderInstanceDialogProps {
readonly environmentId: EnvironmentId;
readonly environmentLabel: string;
readonly onOpenChange: (open: boolean) => void;
+ readonly onCreated?: (instanceId: ProviderInstanceId) => void;
}
export function AddProviderInstanceDialog({
@@ -121,21 +131,33 @@ export function AddProviderInstanceDialog({
environmentId,
environmentLabel,
onOpenChange,
+ onCreated,
}: AddProviderInstanceDialogProps) {
const settings = useEnvironmentSettings(environmentId);
const updateSettings = useUpdateEnvironmentSettings(environmentId);
+ // ACP Registry instances go straight to sign-in, which needs the saved
+ // instance, so their save is awaited instead of fire-and-forget.
+ const persistSettings = useAtomCommand(serverEnvironment.updateSettings, {
+ reportFailure: false,
+ });
const [wizardStep, setWizardStep] = useState(0);
const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND);
- const [label, setLabel] = useState("");
- const [accentColor, setAccentColor] = useState("");
- const [instanceIdOverride, setInstanceIdOverride] = useState(null);
+ const [identityByDriver, setIdentityByDriver] = useState>(
+ {},
+ );
+ const [selectedAcp, setSelectedAcp] = useState(null);
+ const [isManualAcpConfiguration, setIsManualAcpConfiguration] = useState(false);
+ const [isRegistryLoading, setIsRegistryLoading] = useState(false);
+ const [isPreparingRegistryAgent, setIsPreparingRegistryAgent] = useState(false);
// Driver-specific config drafts keyed by driver so toggling between drivers
// during the same dialog session does not lose in-progress input.
const [configByDriver, setConfigByDriver] = useState>>({});
// Errors are suppressed until the user has tried to submit once. After that
// they update live so fixing the problem clears the message in place.
const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+ const [createdInstanceId, setCreatedInstanceId] = useState(null);
const existingIds = useMemo(
() => new Set(Object.keys(settings.providerInstances ?? {})),
@@ -143,6 +165,11 @@ export function AddProviderInstanceDialog({
);
const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION;
+ const isAcpRegistry = driver === ACP_REGISTRY_DRIVER_KIND;
+ const { label, accentColor, instanceIdOverride } = getProviderIdentityDraft(
+ identityByDriver,
+ driver,
+ );
const instanceId = instanceIdOverride ?? deriveInstanceId(driver, label);
const driverSettingsFields = useMemo(
() => deriveProviderSettingsFields(driverOption),
@@ -151,9 +178,18 @@ export function AddProviderInstanceDialog({
const instanceIdError = validateInstanceId(instanceId, existingIds);
const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null;
const previewLabel = label.trim() || `${driverOption.label} Workspace`;
- const wizardStepSummaries = [driverOption.label, previewLabel, null] as const;
const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT;
+ const manualAgentId = typeof configDraft.agentId === "string" ? configDraft.agentId.trim() : "";
+ const acpSelectionError =
+ selectedAcp !== null || (isManualAcpConfiguration && manualAgentId.length > 0)
+ ? null
+ : "Select an ACP agent or configure one manually.";
+ const wizardStepSummaries = isAcpRegistry
+ ? ([selectedAcp?.name ?? (manualAgentId || null), previewLabel, null] as const)
+ : ([driverOption.label, previewLabel, null] as const);
+ const isBusy = isSaving || isPreparingRegistryAgent || createdInstanceId !== null;
+
const setConfigDraft = (config: Record | undefined) => {
setConfigByDriver((existing) => {
const next = { ...existing };
@@ -165,25 +201,85 @@ export function AddProviderInstanceDialog({
return next;
});
};
+ const setIdentityDraft = (update: Partial) => {
+ setIdentityByDriver((existing) => updateProviderIdentityDraft(existing, driver, update));
+ };
const applyWizardNavigation = (navigation: WizardNavigation) => {
+ if (isBusy) return;
if (navigation.kind === "blocked") {
setHasAttemptedSubmit(true);
}
+ if (isAcpRegistry && navigation.kind === "navigate" && navigation.step === 2) {
+ void handleSave();
+ return;
+ }
setWizardStep(navigation.step);
};
const navigateToStep = (requestedStep: number) => {
applyWizardNavigation(
- resolveWizardNavigation(wizardStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, {
- instanceIdError,
+ isAcpRegistry
+ ? resolveAcpRegistryWizardNavigation(wizardStep, requestedStep, {
+ instanceIdError,
+ selectionError: acpSelectionError,
+ })
+ : resolveWizardNavigation(wizardStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, {
+ instanceIdError,
+ }),
+ );
+ };
+
+ /** A prepared registry agent prefills the ACP Registry identity and config. */
+ const handleAcpPrepared = (agent: AcpRegistrySearchAgent) => {
+ const registryIconUrl = resolveOfficialAcpRegistryIconUrl(agent.icon);
+ setDriver(ACP_REGISTRY_DRIVER_KIND);
+ setSelectedAcp(agent);
+ setIsManualAcpConfiguration(false);
+ setIdentityByDriver((existing) =>
+ updateProviderIdentityDraft(existing, ACP_REGISTRY_DRIVER_KIND, {
+ label: agent.name,
+ instanceIdOverride: deriveAvailableInstanceId(
+ (candidateLabel) => deriveInstanceId(ACP_REGISTRY_DRIVER_KIND, candidateLabel),
+ agent.name,
+ existingIds,
+ ),
+ }),
+ );
+ setConfigByDriver((existing) => ({
+ ...existing,
+ [ACP_REGISTRY_DRIVER_KIND]: {
+ agentId: agent.id,
+ distribution: "auto",
+ ...(registryIconUrl ? { registryIconUrl } : {}),
+ },
+ }));
+ setHasAttemptedSubmit(false);
+ setWizardStep(1);
+ };
+
+ const handleManualAcpConfiguration = () => {
+ setDriver(ACP_REGISTRY_DRIVER_KIND);
+ setSelectedAcp(null);
+ setIsManualAcpConfiguration(true);
+ setConfigByDriver((existing) => {
+ const next = { ...existing };
+ delete next[ACP_REGISTRY_DRIVER_KIND];
+ return next;
+ });
+ setIdentityByDriver((existing) =>
+ updateProviderIdentityDraft(existing, ACP_REGISTRY_DRIVER_KIND, {
+ label: "",
+ instanceIdOverride: null,
}),
);
+ setHasAttemptedSubmit(false);
};
- const handleSave = () => {
+ const handleSave = async () => {
+ if (isSaving || createdInstanceId) return;
setHasAttemptedSubmit(true);
- if (instanceIdError !== null) return;
+ if (instanceIdError !== null || (isAcpRegistry && acpSelectionError !== null)) return;
const config = configByDriver[driver] ?? {};
const hasConfig = Object.keys(config).length > 0;
@@ -205,8 +301,30 @@ export function AddProviderInstanceDialog({
...settings.providerInstances,
[brandedId]: nextInstance,
};
+ if (isAcpRegistry) {
+ setIsSaving(true);
+ const result = await persistSettings({
+ environmentId,
+ input: { patch: { providerInstances: nextMap } },
+ });
+ setIsSaving(false);
+ if (result._tag === "Failure") {
+ const error = squashAtomCommandFailure(result);
+ toastManager.add({
+ type: "error",
+ title: "Could not add provider instance",
+ description: error instanceof Error ? error.message : "The settings update failed.",
+ });
+ return;
+ }
+ onCreated?.(brandedId);
+ setCreatedInstanceId(brandedId);
+ setWizardStep(2);
+ return;
+ }
try {
updateSettings({ providerInstances: nextMap });
+ onCreated?.(brandedId);
toastManager.add({
type: "success",
title: "Provider instance added",
@@ -234,189 +352,327 @@ export function AddProviderInstanceDialog({
>
}
>
-
+ {isAcpRegistry ? (
+
+ ) : (
+
+ )}
-
-
-
- Driver
-
-
setDriver(ProviderDriverKind.make(value))}
- aria-labelledby="add-instance-driver-label"
- className="grid grid-cols-1 sm:grid-cols-2"
+ {createdInstanceId ? (
+ onOpenChange(false)}
+ />
+ ) : (
+ <>
+