| 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 | } |