Skip to content
239 linesCodeBlameRaw
1import { Archive, ArchiveRestore, ChevronRight, Trash2 } from "lucide-react";
2import { useEffect, useId, useState } from "react";
3import { useFetcher, useLocation } from "react-router";
4
5import type { ArchivedAgent } from "@g1t/contracts";
6
7import { AgentAvatar } from "../agent-avatar";
8import {
9 AlertDialog,
10 AlertDialogCancel,
11 AlertDialogContent,
12 AlertDialogDescription,
13 AlertDialogFooter,
14 AlertDialogHeader,
15 AlertDialogTitle,
16 AlertDialogTrigger,
17} from "../ui/alert-dialog";
18import { Badge } from "../ui/badge";
19import { Button } from "../ui/button";
20import { Card } from "../ui/card";
21import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible";
22import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
23import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field";
24import { Input, InputAddon, InputGroup } from "../ui/input";
25import { TimeAgo } from "../ui";
26import { confirmsHandle, countsLine } from "../../lib/agents-archived";
27import { type ActionResult, useDialogFetcher } from "./dialogs";
28
29/**
30 * The Archived section at the foot of the Agents page
31 * (docs.g1t.sh/guides/agents/, "Archive, restore and delete"): the
32 * workspace's archived agents for an owner, a member's own archived
33 * personal agents for anyone else. Folded by default with its count;
34 * `#archived` in the address opens it. Each row restores the agent, or
35 * deletes it for good after its handle is typed back.
36 */
37export function ArchivedSection({ slug, agents, owner }: { slug: string; agents: ArchivedAgent[]; owner: boolean }) {
38 const { hash } = useLocation();
39 const [open, setOpen] = useState(false);
40 useEffect(() => {
41 if (hash === "#archived") setOpen(true);
42 }, [hash]);
43 if (agents.length === 0) return null;
44 return (
45 <section id="archived" aria-labelledby="archived-heading">
46 <Collapsible open={open} onOpenChange={setOpen}>
47 <div className="flex items-baseline justify-between gap-3">
48 <h2 id="archived-heading" className="text-sm font-medium">
49 <CollapsibleTrigger className="font-medium text-fg">
50 <ChevronRight size={14} className="shrink-0 text-faint" />
51 <Archive size={14} className="text-faint" />
52 Archived <span className="font-normal text-faint">· {agents.length}</span>
53 </CollapsibleTrigger>
54 </h2>
55 <span className="text-xs text-faint">{owner ? "Restore one, or delete it for good" : "Your archived personal agents"}</span>
56 </div>
57 <CollapsibleContent>
58 <Card asChild divided className="mt-3 overflow-hidden">
59 <ul>
60 {agents.map((agent) => (
61 <ArchivedRow key={agent.id} slug={slug} agent={agent} />
62 ))}
63 </ul>
64 </Card>
65 </CollapsibleContent>
66 </Collapsible>
67 </section>
68 );
69}
70
71/** One archived agent: its face, dimmed, what it was, when it was archived, what is kept about it, and the two ways out. */
72function ArchivedRow({ slug, agent }: { slug: string; agent: ArchivedAgent }) {
73 return (
74 <li className="flex flex-wrap items-center gap-3 px-4 py-3 sm:flex-nowrap">
75 <span className="shrink-0 opacity-60 grayscale-[0.4]">
76 <AgentAvatar agent={{ ...agent, status: null }} size={32} />
77 </span>
78 <div className="min-w-0 grow basis-48">
79 <p className="flex items-center gap-2 text-sm">
80 <span className="truncate font-medium text-muted">{agent.display_name}</span>
81 <span className="truncate text-faint">@{agent.handle}</span>
82 {agent.scope === "personal" && (
83 <Badge tone="neutral" className="shrink-0 font-normal">
84 {agent.personal_owner ? `@${agent.personal_owner}'s` : "Personal"}
85 </Badge>
86 )}
87 </p>
88 <p className="truncate text-xs text-faint">
89 {agent.title || agent.role}
90 {" · "}
91 Archived <TimeAgo at={agent.archived_at} />
92 {agent.archived_by && ` by @${agent.archived_by}`}
93 </p>
94 <p className="truncate text-xs text-faint tabular-nums">{countsLine(agent.counts)}</p>
95 </div>
96 <div className="flex shrink-0 flex-wrap items-center gap-2">
97 <RestoreButton slug={slug} agent={agent} />
98 <DeleteDialog slug={slug} agent={agent} />
99 </div>
100 </li>
101 );
102}
103
104/**
105 * Restore, in one press. When a live agent holds the handle now, the
106 * service says so (`conflict`) and a dialog asks for a new handle to
107 * restore under instead.
108 */
109function RestoreButton({ slug, agent }: { slug: string; agent: ArchivedAgent }) {
110 const fetcher = useFetcher<ActionResult>({ key: `restore-${agent.id}` });
111 const [rename, setRename] = useState(false);
112 const idle = fetcher.state === "idle";
113 const refused = idle && fetcher.data && !fetcher.data.ok ? fetcher.data : null;
114 useEffect(() => {
115 if (refused?.code === "conflict") setRename(true);
116 }, [refused]);
117 const busy = !idle;
118 return (
119 <>
120 <fetcher.Form method="post" action={`/${slug}/-/agents?index`} className="flex flex-col items-end gap-1">
121 <input type="hidden" name="intent" value="restore" />
122 <input type="hidden" name="id" value={agent.id} />
123 <input type="hidden" name="handle" value={agent.handle} />
124 <Button type="submit" variant="outline" size="sm" disabled={busy}>
125 <ArchiveRestore size={14} />
126 {busy ? "Restoring…" : "Restore"}
127 </Button>
128 {refused && refused.code !== "conflict" && (
129 <p role="alert" className="text-xs text-danger">
130 {refused.error}
131 </p>
132 )}
133 </fetcher.Form>
134 <RenameDialog slug={slug} agent={agent} open={rename} onOpenChange={setRename} why={refused?.code === "conflict" ? refused.error : null} />
135 </>
136 );
137}
138
139/** The handle is taken by a live agent: restore under a new one. */
140function RenameDialog({ slug, agent, open, onOpenChange, why }: { slug: string; agent: ArchivedAgent; open: boolean; onOpenChange: (open: boolean) => void; why: string | null }) {
141 const fetcher = useFetcher<ActionResult>({ key: `restore-as-${agent.id}` });
142 const id = useId();
143 useEffect(() => {
144 if (fetcher.state === "idle" && fetcher.data?.ok) onOpenChange(false);
145 }, [fetcher.state, fetcher.data, onOpenChange]);
146 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
147 const busy = fetcher.state !== "idle";
148 return (
149 <Dialog open={open} onOpenChange={onOpenChange}>
150 <DialogContent>
151 <DialogHeader>
152 <DialogTitle>Restore {agent.display_name} under a new handle</DialogTitle>
153 <DialogDescription>{why ?? `A live agent is called @${agent.handle} now. Rename that one first, or restore this one under a new handle.`}</DialogDescription>
154 </DialogHeader>
155 <fetcher.Form method="post" action={`/${slug}/-/agents?index`} className="grid gap-5">
156 <input type="hidden" name="intent" value="restore" />
157 <input type="hidden" name="id" value={agent.id} />
158 <input type="hidden" name="handle" value={agent.handle} />
159 <Field>
160 <FieldLabel htmlFor={`${id}-handle`}>New handle</FieldLabel>
161 <InputGroup>
162 <InputAddon>@</InputAddon>
163 <Input id={`${id}-handle`} name="new_handle" defaultValue={`${agent.handle}-2`} required autoComplete="off" autoFocus />
164 </InputGroup>
165 <FieldDescription>2 to 32 lowercase letters, digits and hyphens. People mention it by this; its name and face stay.</FieldDescription>
166 </Field>
167 <FieldError>{error}</FieldError>
168 <DialogFooter>
169 <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
170 Cancel
171 </Button>
172 <Button type="submit" variant="accent" disabled={busy}>
173 {busy ? "Restoring…" : "Restore"}
174 </Button>
175 </DialogFooter>
176 </fetcher.Form>
177 </DialogContent>
178 </Dialog>
179 );
180}
181
182/**
183 * Delete permanently: asked once more, with what goes and what stays
184 * spelled out, and the handle typed back before the button works.
185 */
186function DeleteDialog({ slug, agent }: { slug: string; agent: ArchivedAgent }) {
187 const { fetcher, open, setOpen, error, busy } = useDialogFetcher(`destroy-${agent.id}`);
188 const [typed, setTyped] = useState("");
189 const id = useId();
190 const kept = countsLine(agent.counts);
191 return (
192 <AlertDialog
193 open={open}
194 onOpenChange={(next) => {
195 setOpen(next);
196 if (!next) setTyped("");
197 }}
198 >
199 <AlertDialogTrigger asChild>
200 <Button type="button" variant="destructive" size="sm">
201 <Trash2 size={14} />
202 Delete permanently
203 </Button>
204 </AlertDialogTrigger>
205 <AlertDialogContent>
206 <AlertDialogHeader>
207 <AlertDialogTitle>Delete {agent.display_name} permanently?</AlertDialogTitle>
208 <AlertDialogDescription asChild>
209 <div className="space-y-3">
210 <p>
211 This deletes <span className="font-medium text-fg">@{agent.handle}</span> for good: its definition and every version, {kept === "Nothing recorded" ? "what little it recorded" : kept.toLowerCase()}, its drafts and suggestions, its budget and its computer&apos;s disk. There is no undo.
212 </p>
213 <p>What stays: its messages in chat, shown under the name it had; what it spent, in the month&apos;s totals on the workspace&apos;s statement and on Usage; and the audit log.</p>
214 </div>
215 </AlertDialogDescription>
216 </AlertDialogHeader>
217 <fetcher.Form method="post" action={`/${slug}/-/agents?index`} className="grid gap-5">
218 <input type="hidden" name="intent" value="destroy" />
219 <input type="hidden" name="id" value={agent.id} />
220 <input type="hidden" name="handle" value={agent.handle} />
221 <Field>
222 <FieldLabel htmlFor={`${id}-confirm`}>
223 Type <span className="font-mono text-fg">@{agent.handle}</span> to confirm
224 </FieldLabel>
225 <Input id={`${id}-confirm`} name="confirm" value={typed} onChange={(event) => setTyped(event.target.value)} autoComplete="off" spellCheck={false} aria-describedby={`${id}-confirm-hint`} />
226 <FieldDescription id={`${id}-confirm-hint`}>Its handle, so nothing goes by accident.</FieldDescription>
227 </Field>
228 <FieldError>{error}</FieldError>
229 <AlertDialogFooter>
230 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
231 <Button type="submit" variant="destructive" disabled={busy || !confirmsHandle(typed, agent.handle)}>
232 {busy ? "Deleting…" : "Delete"}
233 </Button>
234 </AlertDialogFooter>
235 </fetcher.Form>
236 </AlertDialogContent>
237 </AlertDialog>
238 );
239}