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