1import {
2 AuthOrchestrationOperateScope,
3 AuthProvidersManageScope,
4 AuthOrchestrationReadScope,
5} from "@t3tools/contracts";
6import { useEnvironmentScope, readEnvironmentScope } from "~/state/session";
7import type {
8 AcpRegistryConfigurableProvider,
9 AcpRegistrySession,
10 EnvironmentId,
11 ProjectId,
12 ProviderInstanceId,
13 ServerProvider,
14} from "@t3tools/contracts";
15import {
16 isAtomCommandInterrupted,
17 squashAtomCommandFailure,
18 type AtomCommandResult,
19} from "@t3tools/client-runtime/state/runtime";
20import { useState } from "react";
21
22import { ensureLocalApi } from "../../localApi";
23import { serverEnvironment } from "../../state/server";
24import { useAtomCommand } from "../../state/use-atom-command";
25import { Button } from "../ui/button";
26import { Input } from "../ui/input";
27import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
28import { toastManager } from "../ui/toast";
29import { SettingsRow } from "./settingsLayout";
30
31interface AcpSessionProject {
32 readonly id: ProjectId;
33 readonly title: string;
34 readonly workspaceRoot: string;
35}
36
37function reportFailure(title: string, result: AtomCommandResult<unknown, unknown>) {
38 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
39 const error = squashAtomCommandFailure(result);
40 toastManager.add({
41 type: "error",
42 title,
43 description: error instanceof Error ? error.message : "The ACP operation failed.",
44 });
45}
46
47export function AcpSessionManagementSection(props: {
48 readonly environmentId: EnvironmentId;
49 readonly instanceId: ProviderInstanceId;
50 readonly provider: ServerProvider;
51 readonly projects: ReadonlyArray<AcpSessionProject>;
52 readonly readOnly: boolean;
53}) {
54 const canOperate = useEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope);
55 const canRead = useEnvironmentScope(props.environmentId, AuthOrchestrationReadScope);
56 const [projectId, setProjectId] = useState<ProjectId | null>(props.projects[0]?.id ?? null);
57 const [sessions, setSessions] = useState<ReadonlyArray<AcpRegistrySession>>([]);
58 const [nextCursor, setNextCursor] = useState<string | null>(null);
59 const [loading, setLoading] = useState(false);
60 const [importingSessionId, setImportingSessionId] = useState<string | null>(null);
61 const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
62 const [loggingOut, setLoggingOut] = useState(false);
63 const [providers, setProviders] = useState<ReadonlyArray<AcpRegistryConfigurableProvider>>([]);
64 const [loadingProviders, setLoadingProviders] = useState(false);
65 const [savingProviderId, setSavingProviderId] = useState<string | null>(null);
66 const [providerDrafts, setProviderDrafts] = useState<
67 Readonly<Record<string, { apiType: string; baseUrl: string; headers: string }>>
68 >({});
69 const listSessions = useAtomCommand(serverEnvironment.listAcpRegistrySessions, {
70 reportFailure: false,
71 });
72 const importSession = useAtomCommand(serverEnvironment.importAcpRegistrySession, {
73 reportFailure: false,
74 });
75 const deleteSession = useAtomCommand(serverEnvironment.deleteAcpRegistrySession, {
76 reportFailure: false,
77 });
78 const listProviders = useAtomCommand(serverEnvironment.listAcpRegistryProviders, {
79 reportFailure: false,
80 });
81 const setProvider = useAtomCommand(serverEnvironment.setAcpRegistryProvider, {
82 reportFailure: false,
83 });
84 const disableProvider = useAtomCommand(serverEnvironment.disableAcpRegistryProvider, {
85 reportFailure: false,
86 });
87 const logout = useAtomCommand(serverEnvironment.logoutAcpRegistry, { reportFailure: false });
88 const canList = props.provider.nativeSessions?.canList === true;
89 const canImport =
90 props.provider.nativeSessions?.canLoad === true ||
91 props.provider.nativeSessions?.canResume === true;
92 const canLogout =
93 props.provider.auth.canLogout === true && !props.provider.setup?.canAuthenticate;
94 const canDelete = props.provider.nativeSessions?.canDelete === true;
95 const canConfigureProviders = props.provider.configurableProviders === true;
96 const projectOperationPending =
97 loading ||
98 importingSessionId !== null ||
99 deletingSessionId !== null ||
100 loadingProviders ||
101 savingProviderId !== null;
102
103 if (!canList && !canLogout && !canConfigureProviders) return null;
104
105 const loadSessions = async (cursor?: string) => {
106 if (projectId === null || loading) return;
107 setLoading(true);
108 const result = await listSessions({
109 environmentId: props.environmentId,
110 input: {
111 instanceId: props.instanceId,
112 projectId,
113 ...(cursor === undefined ? {} : { cursor }),
114 },
115 });
116 setLoading(false);
117 if (result._tag === "Success") {
118 setSessions((current) =>
119 cursor === undefined ? result.value.sessions : [...current, ...result.value.sessions],
120 );
121 setNextCursor(result.value.nextCursor);
122 return;
123 }
124 reportFailure("Could not list ACP sessions", result);
125 };
126
127 const importNativeSession = async (session: AcpRegistrySession) => {
128 if (
129 !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope) ||
130 projectId === null ||
131 importingSessionId !== null
132 )
133 return;
134 setImportingSessionId(session.sessionId);
135 const result = await importSession({
136 environmentId: props.environmentId,
137 input: {
138 instanceId: props.instanceId,
139 projectId,
140 sessionId: session.sessionId,
141 title: session.title,
142 updatedAt: session.updatedAt,
143 },
144 });
145 setImportingSessionId(null);
146 if (result._tag === "Success") {
147 setSessions((current) =>
148 current.map((candidate) =>
149 candidate.sessionId === session.sessionId
150 ? { ...candidate, importedThreadId: result.value.threadId }
151 : candidate,
152 ),
153 );
154 toastManager.add({
155 type: "success",
156 title: result.value.imported ? "ACP session imported" : "ACP session already imported",
157 });
158 return;
159 }
160 reportFailure("Could not import ACP session", result);
161 };
162
163 const deleteNativeSession = async (session: AcpRegistrySession) => {
164 if (projectId === null || deletingSessionId !== null || session.importedThreadId !== null)
165 return;
166 const confirmed = await ensureLocalApi().dialogs.confirm(
167 `Permanently delete native ACP session "${session.title ?? session.sessionId}"?`,
168 { variant: "destructive" },
169 );
170 if (!confirmed || !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope))
171 return;
172 setDeletingSessionId(session.sessionId);
173 const result = await deleteSession({
174 environmentId: props.environmentId,
175 input: { instanceId: props.instanceId, projectId, sessionId: session.sessionId },
176 });
177 setDeletingSessionId(null);
178 if (result._tag === "Success") {
179 setSessions((current) =>
180 current.filter((candidate) => candidate.sessionId !== session.sessionId),
181 );
182 toastManager.add({ type: "success", title: "ACP session deleted" });
183 return;
184 }
185 reportFailure("Could not delete ACP session", result);
186 };
187
188 const loadProviders = async () => {
189 if (projectId === null || loadingProviders) return;
190 setLoadingProviders(true);
191 const result = await listProviders({
192 environmentId: props.environmentId,
193 input: { instanceId: props.instanceId, projectId },
194 });
195 setLoadingProviders(false);
196 if (result._tag === "Success") {
197 setProviders(result.value.providers);
198 setProviderDrafts(
199 Object.fromEntries(
200 result.value.providers.map((provider) => [
201 provider.providerId,
202 {
203 apiType: provider.current?.apiType ?? provider.supported[0] ?? "",
204 baseUrl: provider.current?.baseUrl ?? "",
205 headers: "",
206 },
207 ]),
208 ),
209 );
210 return;
211 }
212 reportFailure("Could not list ACP providers", result);
213 };
214
215 const saveProvider = async (provider: AcpRegistryConfigurableProvider) => {
216 if (
217 !readEnvironmentScope(props.environmentId, AuthProvidersManageScope) ||
218 projectId === null ||
219 savingProviderId !== null
220 )
221 return;
222 const draft = providerDrafts[provider.providerId];
223 if (draft === undefined || draft.apiType.length === 0 || draft.baseUrl.length === 0) return;
224 let headers: Record<string, string> | undefined;
225 if (draft.headers.trim().length > 0) {
226 try {
227 const parsed: unknown = JSON.parse(draft.headers);
228 if (
229 typeof parsed !== "object" ||
230 parsed === null ||
231 Array.isArray(parsed) ||
232 !Object.values(parsed).every((value) => typeof value === "string")
233 ) {
234 throw new Error("Headers must be a JSON object with string values.");
235 }
236 headers = parsed as Record<string, string>;
237 } catch (error) {
238 toastManager.add({
239 type: "error",
240 title: "Invalid provider headers",
241 description: error instanceof Error ? error.message : "Headers must be valid JSON.",
242 });
243 return;
244 }
245 }
246 setSavingProviderId(provider.providerId);
247 const result = await setProvider({
248 environmentId: props.environmentId,
249 input: {
250 instanceId: props.instanceId,
251 projectId,
252 providerId: provider.providerId,
253 apiType: draft.apiType,
254 baseUrl: draft.baseUrl,
255 ...(headers === undefined ? {} : { headers }),
256 },
257 });
258 setSavingProviderId(null);
259 if (result._tag === "Success") {
260 toastManager.add({ type: "success", title: "ACP provider configured" });
261 await loadProviders();
262 return;
263 }
264 reportFailure("Could not configure ACP provider", result);
265 };
266
267 const disableConfiguredProvider = async (provider: AcpRegistryConfigurableProvider) => {
268 if (projectId === null || savingProviderId !== null || provider.required) return;
269 const confirmed = await ensureLocalApi().dialogs.confirm(
270 `Disable ACP provider "${provider.providerId}"?`,
271 { variant: "destructive" },
272 );
273 if (!confirmed || !readEnvironmentScope(props.environmentId, AuthProvidersManageScope)) return;
274 setSavingProviderId(provider.providerId);
275 const result = await disableProvider({
276 environmentId: props.environmentId,
277 input: { instanceId: props.instanceId, projectId, providerId: provider.providerId },
278 });
279 setSavingProviderId(null);
280 if (result._tag === "Success") {
281 toastManager.add({ type: "success", title: "ACP provider disabled" });
282 await loadProviders();
283 return;
284 }
285 reportFailure("Could not disable ACP provider", result);
286 };
287
288 const logoutProvider = async () => {
289 if (loggingOut || !readEnvironmentScope(props.environmentId, AuthProvidersManageScope)) return;
290 setLoggingOut(true);
291 const result = await logout({
292 environmentId: props.environmentId,
293 input: { instanceId: props.instanceId },
294 });
295 setLoggingOut(false);
296 if (result._tag === "Success") {
297 setSessions([]);
298 setNextCursor(null);
299 toastManager.add({ type: "success", title: "Logged out of ACP agent" });
300 return;
301 }
302 reportFailure("Could not log out of ACP agent", result);
303 };
304
305 return (
306 <div className="grid gap-3">
307 <SettingsRow
308 title="Native sessions"
309 description="Resume agent-owned conversations as T3 threads."
310 status={
311 canList && props.projects.length === 0
312 ? "Add a project before importing sessions."
313 : undefined
314 }
315 control={
316 <div className="flex flex-wrap items-center gap-2">
317 {canLogout ? (
318 <Button
319 type="button"
320 size="sm"
321 variant="outline"
322 disabled={props.readOnly || loggingOut}
323 onClick={() => void logoutProvider()}
324 >
325 {loggingOut ? "Logging out" : "Log out"}
326 </Button>
327 ) : null}
328 {canList && props.projects.length > 0 ? (
329 <>
330 <Select
331 value={projectId ?? ""}
332 disabled={!canRead || projectOperationPending}
333 onValueChange={(value) => {
334 if (value === null) return;
335 setProjectId(value as ProjectId);
336 setSessions([]);
337 setNextCursor(null);
338 setProviders([]);
339 setProviderDrafts({});
340 }}
341 >
342 <SelectTrigger aria-label="Project for ACP sessions" className="w-40" size="sm">
343 <SelectValue>
344 {props.projects.find((project) => project.id === projectId)?.title}
345 </SelectValue>
346 </SelectTrigger>
347 <SelectPopup>
348 {props.projects.map((project) => (
349 <SelectItem key={project.id} value={project.id}>
350 {project.title}
351 </SelectItem>
352 ))}
353 </SelectPopup>
354 </Select>
355 <Button
356 type="button"
357 size="sm"
358 variant="outline"
359 disabled={!canRead || loading || projectId === null}
360 onClick={() => void loadSessions()}
361 >
362 {loading ? "Loading" : sessions.length === 0 ? "List sessions" : "Refresh"}
363 </Button>
364 </>
365 ) : null}
366 </div>
367 }
368 >
369 {canList && (sessions.length > 0 || nextCursor !== null) ? (
370 <>
371 {sessions.length > 0 ? (
372 <div className="divide-y divide-border/60 border-y border-border/60">
373 {sessions.map((session) => (
374 <div
375 key={session.sessionId}
376 className="flex min-w-0 items-center justify-between gap-3 py-2"
377 >
378 <div className="min-w-0">
379 <p className="truncate text-xs text-foreground">
380 {session.title ?? session.sessionId}
381 </p>
382 <code className="block truncate text-3xs text-muted-foreground">
383 {session.sessionId}
384 </code>
385 </div>
386 <div className="flex shrink-0 items-center gap-1">
387 <Button
388 type="button"
389 size="xs"
390 variant="ghost-muted"
391 disabled={
392 !canOperate ||
393 !canImport ||
394 session.importedThreadId !== null ||
395 importingSessionId !== null
396 }
397 onClick={() => void importNativeSession(session)}
398 >
399 {session.importedThreadId !== null
400 ? "Imported"
401 : importingSessionId === session.sessionId
402 ? "Importing"
403 : "Import"}
404 </Button>
405 {canDelete ? (
406 <Button
407 type="button"
408 size="xs"
409 variant="ghost-muted"
410 disabled={
411 !canOperate ||
412 session.importedThreadId !== null ||
413 deletingSessionId !== null
414 }
415 onClick={() => void deleteNativeSession(session)}
416 >
417 {deletingSessionId === session.sessionId ? "Deleting" : "Delete"}
418 </Button>
419 ) : null}
420 </div>
421 </div>
422 ))}
423 </div>
424 ) : null}
425
426 {nextCursor !== null ? (
427 <Button
428 type="button"
429 size="xs"
430 variant="ghost-muted"
431 className="w-fit"
432 disabled={!canRead || loading}
433 onClick={() => void loadSessions(nextCursor)}
434 >
435 {loading ? "Loading" : "Load more"}
436 </Button>
437 ) : null}
438 </>
439 ) : null}
440 </SettingsRow>
441
442 {canConfigureProviders ? (
443 <div className="grid gap-3 border-t border-border/60 px-3 py-3 sm:px-4">
444 <div className="flex flex-wrap items-center justify-between gap-2">
445 <div>
446 <p className="text-xs font-medium text-foreground">Agent providers</p>
447 <p className="text-xs text-muted-foreground">
448 Configure non-secret routing. Headers are write-only.
449 </p>
450 </div>
451 <Button
452 type="button"
453 size="xs"
454 variant="outline"
455 disabled={!canRead || loadingProviders || projectId === null}
456 onClick={() => void loadProviders()}
457 >
458 {loadingProviders ? "Loading" : providers.length === 0 ? "List providers" : "Refresh"}
459 </Button>
460 </div>
461
462 {!canList && props.projects.length > 1 ? (
463 <Select
464 value={projectId ?? ""}
465 disabled={!canRead || projectOperationPending}
466 onValueChange={(value) => {
467 if (value === null) return;
468 setProjectId(value as ProjectId);
469 setProviders([]);
470 setProviderDrafts({});
471 }}
472 >
473 <SelectTrigger aria-label="Project for ACP providers" className="min-w-48" size="xs">
474 <SelectValue>
475 {props.projects.find((project) => project.id === projectId)?.title}
476 </SelectValue>
477 </SelectTrigger>
478 <SelectPopup>
479 {props.projects.map((project) => (
480 <SelectItem key={project.id} value={project.id}>
481 {project.title}
482 </SelectItem>
483 ))}
484 </SelectPopup>
485 </Select>
486 ) : null}
487
488 {providers.length > 0 ? (
489 <div className="divide-y divide-border/60 border-y border-border/60">
490 {providers.map((provider) => {
491 const draft = providerDrafts[provider.providerId] ?? {
492 apiType: provider.current?.apiType ?? provider.supported[0] ?? "",
493 baseUrl: provider.current?.baseUrl ?? "",
494 headers: "",
495 };
496 const updateDraft = (update: Partial<typeof draft>) =>
497 setProviderDrafts((current) => ({
498 ...current,
499 [provider.providerId]: { ...draft, ...update },
500 }));
501 return (
502 <div key={provider.providerId} className="grid gap-2 py-3">
503 <div className="flex items-center justify-between gap-2">
504 <div className="min-w-0">
505 <p className="truncate text-xs font-medium text-foreground">
506 {provider.providerId}
507 </p>
508 <p className="text-3xs text-muted-foreground">
509 {provider.current === null ? "Disabled" : "Configured"}
510 {provider.required ? " · Required" : ""}
511 </p>
512 </div>
513 <div className="flex items-center gap-1">
514 <Button
515 type="button"
516 size="xs"
517 variant="outline"
518 disabled={
519 props.readOnly ||
520 savingProviderId !== null ||
521 draft.apiType.length === 0 ||
522 draft.baseUrl.length === 0
523 }
524 onClick={() => void saveProvider(provider)}
525 >
526 {savingProviderId === provider.providerId ? "Saving" : "Save"}
527 </Button>
528 {!provider.required && provider.current !== null ? (
529 <Button
530 type="button"
531 size="xs"
532 variant="ghost-muted"
533 disabled={props.readOnly || savingProviderId !== null}
534 onClick={() => void disableConfiguredProvider(provider)}
535 >
536 Disable
537 </Button>
538 ) : null}
539 </div>
540 </div>
541 <div className="grid gap-2 sm:grid-cols-[9rem_minmax(0,1fr)]">
542 <Select
543 value={draft.apiType}
544 disabled={props.readOnly || savingProviderId !== null}
545 onValueChange={(value) => {
546 if (value !== null) updateDraft({ apiType: value });
547 }}
548 >
549 <SelectTrigger aria-label={`${provider.providerId} protocol`} size="sm">
550 <SelectValue>{draft.apiType}</SelectValue>
551 </SelectTrigger>
552 <SelectPopup>
553 {provider.supported.map((protocol) => (
554 <SelectItem key={protocol} value={protocol}>
555 {protocol}
556 </SelectItem>
557 ))}
558 </SelectPopup>
559 </Select>
560 <Input
561 size="sm"
562 type="url"
563 aria-label={`${provider.providerId} base URL`}
564 placeholder="https://api.example.com"
565 value={draft.baseUrl}
566 disabled={props.readOnly || savingProviderId !== null}
567 onValueChange={(value) => updateDraft({ baseUrl: value })}
568 />
569 </div>
570 <Input
571 size="sm"
572 type="password"
573 aria-label={`${provider.providerId} write-only headers JSON`}
574 placeholder='Write-only headers JSON, e.g. {"Authorization":"Bearer …"}'
575 value={draft.headers}
576 disabled={props.readOnly || savingProviderId !== null}
577 onValueChange={(value) => updateDraft({ headers: value })}
578 />
579 </div>
580 );
581 })}
582 </div>
583 ) : null}
584 </div>
585 ) : null}
586 </div>
587 );
588}