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