Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| An archived agent can be found, brought back, or deleted for good. The Agents page ends with Archived, folded, where owners see every archived agent and members their own personal ones, each with when and by whom it was archived and what deleting it would take; Restore brings it back, under a new handle when a live agent has taken its old one, and Delete permanently asks for the handle to be typed and then removes its sessions, replies, memories, routines, versions, drafts, skills and its computer's home, leaving its messages in chat under its saved name, the workspace's spend totals and the audit log. In Chat an archived agent is deactivated the way people expect: never offered as someone to message or mention, never woken, a conversation with it kept in the list, dimmed and marked Archived, open to read with a note in place of the composer and a way to restore it for owners; after deletion the conversation still reads, its messages marked as a deleted agent's. The agents, chat and agent budgets guides say so. | 1 | import { Archive, ArchiveRestore, ChevronRight, Trash2 } from "lucide-react"; |
| 2 | import { useEffect, useId, useState } from "react"; | |
| 3 | import { useFetcher, useLocation } from "react-router"; | |
| 4 | ||
| 5 | import type { ArchivedAgent } from "@g1t/contracts"; | |
| 6 | ||
| 7 | import { AgentAvatar } from "../agent-avatar"; | |
| 8 | import { | |
| 9 | AlertDialog, | |
| 10 | AlertDialogCancel, | |
| 11 | AlertDialogContent, | |
| 12 | AlertDialogDescription, | |
| 13 | AlertDialogFooter, | |
| 14 | AlertDialogHeader, | |
| 15 | AlertDialogTitle, | |
| 16 | AlertDialogTrigger, | |
| 17 | } from "../ui/alert-dialog"; | |
| 18 | import { Badge } from "../ui/badge"; | |
| 19 | import { Button } from "../ui/button"; | |
| 20 | import { Card } from "../ui/card"; | |
| 21 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; | |
| 22 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog"; | |
| 23 | import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field"; | |
| 24 | import { Input, InputAddon, InputGroup } from "../ui/input"; | |
| 25 | import { TimeAgo } from "../ui"; | |
| 26 | import { confirmsHandle, countsLine } from "../../lib/agents-archived"; | |
| 27 | import { 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 | */ | |
| 37 | export 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. */ | |
| 72 | function 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 | */ | |
| 109 | function 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. */ | |
| 140 | function 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 | */ | |
| 186 | function 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'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's totals on the workspace'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 | } |