1import { AuthProvidersManageScope } from "@t3tools/contracts";
2import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
3import {
4 isAtomCommandInterrupted,
5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";
7import type {
8 AcpRegistryPrepareResult,
9 AcpRegistrySearchAgent,
10 EnvironmentId,
11 ProviderInstanceConfig,
12} from "@t3tools/contracts";
13import { ExternalLinkIcon, SearchIcon } from "lucide-react";
14import { type FormEvent, useEffect, useLayoutEffect, useRef, useState } from "react";
15
16import { serverEnvironment } from "../../state/server";
17import { useEnvironmentQuery } from "../../state/query";
18import { useAtomCommand } from "../../state/use-atom-command";
19import { Alert, AlertDescription } from "../ui/alert";
20import { Button } from "../ui/button";
21import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
22import { ScrollArea } from "../ui/scroll-area";
23import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
24import { isConfiguredAcpRegistryAgent } from "./AddProviderInstanceDialog.logic";
25import { ProviderDriverKind } from "@t3tools/contracts";
26import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
27
28function errorMessage(error: unknown): string {
29 return error instanceof Error && error.message.trim()
30 ? error.message
31 : "The ACP could not be prepared.";
32}
33
34interface AcpRegistrySearchStepProps {
35 readonly environmentId: EnvironmentId;
36 readonly providerInstances: Readonly<Record<string, ProviderInstanceConfig>>;
37 readonly onPrepared: (agent: AcpRegistrySearchAgent) => void;
38 readonly onManualConfiguration: () => void;
39 readonly onLocalConfiguration?: () => void;
40 readonly onLoadingChange?: (loading: boolean) => void;
41 readonly onPreparingChange?: (preparing: boolean) => void;
42}
43
44function applyAcpRegistryPrepareResult(
45 agent: AcpRegistrySearchAgent,
46 prepared: AcpRegistryPrepareResult,
47): AcpRegistrySearchAgent {
48 return {
49 ...agent,
50 id: prepared.agentId,
51 version: prepared.version,
52 distribution: prepared.distribution,
53 };
54}
55
56export function AcpRegistrySearchStep({
57 environmentId,
58 providerInstances,
59 onPrepared,
60 onManualConfiguration,
61 onLocalConfiguration,
62 onLoadingChange,
63 onPreparingChange,
64}: AcpRegistrySearchStepProps) {
65 const [query, setQuery] = useState("");
66 // An empty registry query is the compact compatible catalog. Start there so
67 // entering this step is useful before the user knows what to search for.
68 const [submittedQuery, setSubmittedQuery] = useState("");
69 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
70 const [preparingId, setPreparingId] = useState<string | null>(null);
71 const [prepareError, setPrepareError] = useState<string | null>(null);
72 const prepareGeneration = useRef(0);
73 const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
74 const search = useEnvironmentQuery(
75 serverEnvironment.searchAcpRegistry({
76 environmentId,
77 input: { query: submittedQuery },
78 }),
79 );
80 const prepareAgent = useAtomCommand(serverEnvironment.prepareAcpRegistryAgent, {
81 reportFailure: false,
82 });
83
84 useEffect(
85 () => () => {
86 prepareGeneration.current += 1;
87 if (searchTimer.current !== null) clearTimeout(searchTimer.current);
88 onPreparingChange?.(false);
89 },
90 [onPreparingChange],
91 );
92
93 const submitSearch = (nextQuery: string) => {
94 if (searchTimer.current !== null) clearTimeout(searchTimer.current);
95 const trimmed = nextQuery.trim();
96 setQuery(trimmed);
97 setPrepareError(null);
98 if (trimmed === submittedQuery) {
99 search.refresh();
100 } else {
101 setSubmittedQuery(trimmed);
102 }
103 };
104
105 const handleSearch = (event: FormEvent<HTMLFormElement>) => {
106 event.preventDefault();
107 submitSearch(query);
108 };
109
110 const handlePrepare = async (agent: AcpRegistrySearchAgent) => {
111 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
112 const generation = ++prepareGeneration.current;
113 setPrepareError(null);
114 setPreparingId(agent.id);
115 onPreparingChange?.(true);
116 const result = await prepareAgent({ environmentId, input: { agentId: agent.id } });
117 if (prepareGeneration.current !== generation) return;
118 setPreparingId(null);
119 onPreparingChange?.(false);
120 if (result._tag === "Success") {
121 onPrepared(applyAcpRegistryPrepareResult(agent, result.value));
122 return;
123 }
124 if (!isAtomCommandInterrupted(result)) {
125 setPrepareError(errorMessage(squashAtomCommandFailure(result)));
126 }
127 };
128
129 const results = search.data?.agents ?? null;
130 const isInitialSearch = search.isPending && results === null;
131 const isRefreshing = search.isPending && results !== null;
132 const resultCount = results?.length ?? 0;
133
134 useLayoutEffect(() => {
135 onLoadingChange?.(search.isPending);
136 return () => onLoadingChange?.(false);
137 }, [onLoadingChange, search.isPending]);
138
139 return (
140 <section className="grid gap-3" aria-labelledby="acp-registry-search-heading">
141 <h3 className="sr-only" id="acp-registry-search-heading">
142 Choose an agent
143 </h3>
144
145 <form className="flex flex-wrap items-center gap-2" onSubmit={handleSearch}>
146 <InputGroup className="min-w-40 flex-1">
147 <InputGroupAddon>
148 <SearchIcon />
149 </InputGroupAddon>
150 <InputGroupInput
151 aria-label="Search ACP Registry"
152 disabled={preparingId !== null}
153 onChange={(event) => {
154 const nextQuery = event.currentTarget.value;
155 setQuery(nextQuery);
156 setPrepareError(null);
157 if (searchTimer.current !== null) clearTimeout(searchTimer.current);
158 searchTimer.current = setTimeout(() => {
159 searchTimer.current = null;
160 setSubmittedQuery(nextQuery.trim());
161 }, 300);
162 }}
163 placeholder="Search agents…"
164 size="sm"
165 type="search"
166 value={query}
167 />
168 </InputGroup>
169 <Button
170 disabled={preparingId !== null}
171 onClick={onManualConfiguration}
172 size="sm"
173 type="button"
174 variant="ghost-muted"
175 >
176 Enter manually
177 </Button>
178 {onLocalConfiguration ? (
179 <Button
180 disabled={preparingId !== null}
181 onClick={onLocalConfiguration}
182 size="sm"
183 type="button"
184 variant="outline"
185 >
186 Local ACP command
187 </Button>
188 ) : null}
189 </form>
190
191 <div className="sr-only" role="status">
192 {isInitialSearch
193 ? "Searching the ACP Registry."
194 : isRefreshing
195 ? "Refreshing ACP Registry results."
196 : results
197 ? `${resultCount} compatible ${resultCount === 1 ? "agent" : "agents"} found.`
198 : ""}
199 </div>
200
201 {search.error || prepareError ? (
202 <Alert variant="error" role="status" aria-live="polite">
203 <AlertDescription>{prepareError ?? search.error}</AlertDescription>
204 </Alert>
205 ) : null}
206
207 {isInitialSearch ? (
208 <div className="flex min-h-20 items-center justify-center text-sm text-muted-foreground">
209 Searching the registry...
210 </div>
211 ) : null}
212
213 {results ? (
214 results.length === 0 ? (
215 <div className="flex min-h-28 flex-col items-center justify-center rounded-2xl border border-dashed px-4 text-center">
216 <p className="text-sm font-medium">No compatible agents found</p>
217 <p className="mt-1 text-xs text-muted-foreground">Try a broader search.</p>
218 </div>
219 ) : (
220 <ScrollArea scrollFade className="max-h-64">
221 {/* The overlay scrollbar takes no layout space, so the rows
222 reserve its lane explicitly. */}
223 <div className="pr-2.5">
224 {results.map((agent) => {
225 const alreadyAdded = isConfiguredAcpRegistryAgent(providerInstances, agent.id);
226 const isPreparing = preparingId === agent.id;
227 const progressLabel = agent.distribution === "binary" ? "Downloading" : "Preparing";
228 return (
229 <article className="min-w-0 py-2.5" key={agent.id}>
230 <div className="flex min-w-0 items-center justify-between gap-3">
231 <div className="flex min-w-0 flex-1 items-center gap-3">
232 <ProviderInstanceIcon
233 driverKind={ProviderDriverKind.make("acpRegistry")}
234 displayName={agent.name}
235 acpRegistryAgentId={agent.id}
236 acpRegistryIconUrl={agent.icon ?? undefined}
237 iconClassName="size-6 rounded-md text-muted-foreground"
238 />
239 <div className="min-w-0 flex-1">
240 <div className="flex min-w-0 items-baseline gap-2">
241 <h4 className="min-w-0 truncate text-sm font-medium text-foreground">
242 {agent.name}
243 </h4>
244 </div>
245 {agent.description ? (
246 <p className="mt-0.5 truncate text-xs text-muted-foreground">
247 {agent.description}
248 </p>
249 ) : null}
250 </div>
251 </div>
252 <div className="flex shrink-0 items-center gap-1.5">
253 {agent.website || agent.repository ? (
254 <Tooltip>
255 <TooltipTrigger
256 render={
257 <Button
258 size="icon-xs"
259 variant="ghost-muted"
260 aria-label={`About ${agent.name}`}
261 render={
262 <a
263 href={agent.website || agent.repository || undefined}
264 target="_blank"
265 rel="noreferrer"
266 />
267 }
268 >
269 <ExternalLinkIcon />
270 </Button>
271 }
272 />
273 <TooltipPopup>About {agent.name}</TooltipPopup>
274 </Tooltip>
275 ) : null}
276 <Button
277 aria-label={`${alreadyAdded ? "Already added" : isPreparing ? progressLabel : "Add"} ${agent.name}`}
278 disabled={!canManageProviders || alreadyAdded || preparingId !== null}
279 onClick={() => void handlePrepare(agent)}
280 size="xs"
281 variant={isPreparing ? "secondary" : "outline"}
282 >
283 {alreadyAdded ? "Added" : isPreparing ? progressLabel : "Add"}
284 </Button>
285 </div>
286 </div>
287 </article>
288 );
289 })}
290 </div>
291 </ScrollArea>
292 )
293 ) : null}
294 </section>
295 );
296}