g1t/apps/web/app/components/memory.tsx
| 1 | /** |
| 2 | * Memory, as a project's and a workspace's pages show it: what agents and |
| 3 | * people learned, each with where it came from, to pin, edit or forget. |
| 4 | * Both pages post to their own action with the intents in `memoryAction`. |
| 5 | */ |
| 6 | import { Bot, Brain, Pencil, Pin, PinOff, Trash2, User } from "lucide-react"; |
| 7 | import { useEffect, useRef, useState } from "react"; |
| 8 | import { Link, useFetcher } from "react-router"; |
| 9 | |
| 10 | import { MEMORY_KINDS, type Memory, type MemoryKind, type MemoryScope, type Result, type User as Actor } from "@g1t/contracts"; |
| 11 | |
| 12 | import { TimeAgo } from "./ui"; |
| 13 | import { |
| 14 | AlertDialog, |
| 15 | AlertDialogAction, |
| 16 | AlertDialogCancel, |
| 17 | AlertDialogContent, |
| 18 | AlertDialogDescription, |
| 19 | AlertDialogFooter, |
| 20 | AlertDialogHeader, |
| 21 | AlertDialogTitle, |
| 22 | AlertDialogTrigger, |
| 23 | } from "./ui/alert-dialog"; |
| 24 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; |
| 25 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; |
| 26 | import { Switch } from "./ui/switch"; |
| 27 | |
| 28 | const KIND: Record<MemoryKind, { label: string; about: string; tone: string }> = { |
| 29 | fact: { label: "Fact", about: "How something is", tone: "text-info border-info/40" }, |
| 30 | convention: { label: "Convention", about: "How things are done here", tone: "text-accent border-accent/40" }, |
| 31 | decision: { label: "Decision", about: "Something chosen, and why", tone: "text-merged border-merged/40" }, |
| 32 | gotcha: { label: "Gotcha", about: "A trap to avoid", tone: "text-warn border-warn/40" }, |
| 33 | }; |
| 34 | |
| 35 | const TEXTAREA = |
| 36 | "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"; |
| 37 | |
| 38 | type Done = { ok: boolean; error?: string } | undefined; |
| 39 | |
| 40 | function KindBadge({ kind }: { kind: MemoryKind }) { |
| 41 | return ( |
| 42 | <span className={`inline-flex shrink-0 items-center rounded-full border px-2 py-px text-[0.6875rem] ${KIND[kind].tone}`}> |
| 43 | {KIND[kind].label} |
| 44 | </span> |
| 45 | ); |
| 46 | } |
| 47 | |
| 48 | function KindSelect({ value }: { value?: MemoryKind }) { |
| 49 | return ( |
| 50 | <Select name="kind" defaultValue={value ?? "fact"}> |
| 51 | <SelectTrigger size="sm" className="w-40"> |
| 52 | <SelectValue /> |
| 53 | </SelectTrigger> |
| 54 | <SelectContent> |
| 55 | {MEMORY_KINDS.map((kind) => ( |
| 56 | <SelectItem key={kind} value={kind} description={KIND[kind].about}> |
| 57 | {KIND[kind].label} |
| 58 | </SelectItem> |
| 59 | ))} |
| 60 | </SelectContent> |
| 61 | </Select> |
| 62 | ); |
| 63 | } |
| 64 | |
| 65 | /** Where a memory came from, linked where it can be. */ |
| 66 | function Source({ memory }: { memory: Memory }) { |
| 67 | const { source } = memory; |
| 68 | const repo = source.repo; |
| 69 | const where = |
| 70 | repo && source.number != null ? ( |
| 71 | <> |
| 72 | {" "} |
| 73 | on{" "} |
| 74 | <Link to={`/${repo.namespace}/${repo.name}/pull/${source.number}`} className="hover:text-fg"> |
| 75 | {memory.scope === "workspace" && `${repo.name}`}#{source.number} |
| 76 | </Link> |
| 77 | </> |
| 78 | ) : repo && memory.scope === "workspace" ? ( |
| 79 | <> |
| 80 | {" "} |
| 81 | in <Link to={`/${repo.namespace}/${repo.name}`} className="hover:text-fg">{repo.name}</Link> |
| 82 | </> |
| 83 | ) : null; |
| 84 | if (source.kind === "person") { |
| 85 | return ( |
| 86 | <span className="inline-flex items-center gap-1"> |
| 87 | <User size={11} /> |
| 88 | {memory.createdBy} |
| 89 | {where} |
| 90 | </span> |
| 91 | ); |
| 92 | } |
| 93 | return ( |
| 94 | <span className="inline-flex items-center gap-1"> |
| 95 | <Bot size={11} /> |
| 96 | {source.runId && repo ? ( |
| 97 | <Link to={`/${repo.namespace}/${repo.name}/agents/runs/${source.runId}`} className="hover:text-fg"> |
| 98 | {memory.createdBy}'s run |
| 99 | </Link> |
| 100 | ) : ( |
| 101 | memory.createdBy |
| 102 | )} |
| 103 | {where} |
| 104 | </span> |
| 105 | ); |
| 106 | } |
| 107 | |
| 108 | function EditMemory({ memory, action }: { memory: Memory; action: string }) { |
| 109 | const fetcher = useFetcher<Done>(); |
| 110 | const [open, setOpen] = useState(false); |
| 111 | useEffect(() => { |
| 112 | if (fetcher.state === "idle" && fetcher.data?.ok) setOpen(false); |
| 113 | }, [fetcher.state, fetcher.data]); |
| 114 | return ( |
| 115 | <Dialog open={open} onOpenChange={setOpen}> |
| 116 | <DialogTrigger aria-label="Edit" className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg"> |
| 117 | <Pencil size={13} /> |
| 118 | </DialogTrigger> |
| 119 | <DialogContent> |
| 120 | <DialogHeader> |
| 121 | <DialogTitle>Edit memory</DialogTitle> |
| 122 | <DialogDescription>Agents get the new wording from their next run on.</DialogDescription> |
| 123 | </DialogHeader> |
| 124 | <fetcher.Form method="post" action={action} className="space-y-3"> |
| 125 | <input type="hidden" name="intent" value="update" /> |
| 126 | <input type="hidden" name="id" value={memory.id} /> |
| 127 | <textarea name="text" required rows={4} maxLength={1000} defaultValue={memory.text} className={TEXTAREA} /> |
| 128 | <div className="flex items-center justify-between gap-3"> |
| 129 | <KindSelect value={memory.kind} /> |
| 130 | <button |
| 131 | type="submit" |
| 132 | disabled={fetcher.state !== "idle"} |
| 133 | className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50" |
| 134 | > |
| 135 | Save |
| 136 | </button> |
| 137 | </div> |
| 138 | {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>} |
| 139 | </fetcher.Form> |
| 140 | </DialogContent> |
| 141 | </Dialog> |
| 142 | ); |
| 143 | } |
| 144 | |
| 145 | function MemoryItem({ memory, action, editable }: { memory: Memory; action: string; editable: boolean }) { |
| 146 | const fetcher = useFetcher<Done>(); |
| 147 | const pinned = fetcher.formData ? fetcher.formData.get("pinned") === "true" : memory.pinned; |
| 148 | const gone = fetcher.formData?.get("intent") === "delete"; |
| 149 | if (gone) return null; |
| 150 | return ( |
| 151 | <li className="group flex gap-3 px-4 py-3"> |
| 152 | <div className="min-w-0 grow"> |
| 153 | <div className="flex flex-wrap items-center gap-2"> |
| 154 | <KindBadge kind={memory.kind} /> |
| 155 | {pinned && ( |
| 156 | <span className="inline-flex items-center gap-1 text-[0.6875rem] text-accent"> |
| 157 | <Pin size={11} /> |
| 158 | pinned |
| 159 | </span> |
| 160 | )} |
| 161 | </div> |
| 162 | <p className="mt-1.5 text-sm whitespace-pre-wrap">{memory.text}</p> |
| 163 | <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-faint"> |
| 164 | <Source memory={memory} /> |
| 165 | <span> |
| 166 | added <TimeAgo at={memory.createdAt} /> |
| 167 | </span> |
| 168 | <span>{memory.lastUsedAt ? <>given to an agent <TimeAgo at={memory.lastUsedAt} /></> : "not given to an agent yet"}</span> |
| 169 | </p> |
| 170 | {fetcher.data?.error && <p className="mt-1.5 text-xs text-danger">{fetcher.data.error}</p>} |
| 171 | </div> |
| 172 | {editable && ( |
| 173 | <div className="flex shrink-0 items-start gap-0.5"> |
| 174 | <button |
| 175 | type="button" |
| 176 | aria-label={pinned ? "Unpin" : "Pin"} |
| 177 | title={pinned ? "Unpin" : "Pin: given to every agent first"} |
| 178 | onClick={() => fetcher.submit({ intent: "update", id: memory.id, pinned: String(!pinned) }, { method: "post", action })} |
| 179 | className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg" |
| 180 | > |
| 181 | {pinned ? <PinOff size={13} /> : <Pin size={13} />} |
| 182 | </button> |
| 183 | <EditMemory memory={memory} action={action} /> |
| 184 | <AlertDialog> |
| 185 | <AlertDialogTrigger aria-label="Forget" className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-danger"> |
| 186 | <Trash2 size={13} /> |
| 187 | </AlertDialogTrigger> |
| 188 | <AlertDialogContent> |
| 189 | <AlertDialogHeader> |
| 190 | <AlertDialogTitle>Forget this?</AlertDialogTitle> |
| 191 | <AlertDialogDescription> |
| 192 | No agent is given it again. This cannot be undone. |
| 193 | </AlertDialogDescription> |
| 194 | </AlertDialogHeader> |
| 195 | <AlertDialogFooter> |
| 196 | <AlertDialogCancel>Keep it</AlertDialogCancel> |
| 197 | <AlertDialogAction onClick={() => fetcher.submit({ intent: "delete", id: memory.id }, { method: "post", action })}> |
| 198 | Forget |
| 199 | </AlertDialogAction> |
| 200 | </AlertDialogFooter> |
| 201 | </AlertDialogContent> |
| 202 | </AlertDialog> |
| 203 | </div> |
| 204 | )} |
| 205 | </li> |
| 206 | ); |
| 207 | } |
| 208 | |
| 209 | export function MemoryList({ |
| 210 | memories, |
| 211 | action, |
| 212 | editable, |
| 213 | empty, |
| 214 | }: { |
| 215 | memories: Memory[]; |
| 216 | action: string; |
| 217 | editable: boolean; |
| 218 | empty: string; |
| 219 | }) { |
| 220 | if (memories.length === 0) { |
| 221 | return <p className="rounded-xl border border-dashed border-line px-4 py-6 text-sm text-muted">{empty}</p>; |
| 222 | } |
| 223 | return ( |
| 224 | <ul className="divide-y divide-line rounded-xl border border-line bg-surface"> |
| 225 | {memories.map((memory) => ( |
| 226 | <MemoryItem key={memory.id} memory={memory} action={action} editable={editable} /> |
| 227 | ))} |
| 228 | </ul> |
| 229 | ); |
| 230 | } |
| 231 | |
| 232 | /** A form to add a memory at one level. */ |
| 233 | export function AddMemory({ scope, action, placeholder }: { scope: MemoryScope; action: string; placeholder: string }) { |
| 234 | const fetcher = useFetcher<Done>(); |
| 235 | const form = useRef<HTMLFormElement>(null); |
| 236 | useEffect(() => { |
| 237 | if (fetcher.state === "idle" && fetcher.data?.ok) form.current?.reset(); |
| 238 | }, [fetcher.state, fetcher.data]); |
| 239 | return ( |
| 240 | <fetcher.Form ref={form} method="post" action={action} className="rounded-xl border border-line bg-surface p-4"> |
| 241 | <input type="hidden" name="intent" value="add" /> |
| 242 | <input type="hidden" name="scope" value={scope} /> |
| 243 | <label className="flex items-center gap-2 text-sm font-medium"> |
| 244 | <Brain size={15} className="text-merged" /> |
| 245 | Add to {scope === "workspace" ? "the workspace's" : "this project's"} memory |
| 246 | </label> |
| 247 | <textarea name="text" required rows={2} maxLength={1000} placeholder={placeholder} className={`${TEXTAREA} mt-3`} /> |
| 248 | <div className="mt-3 flex flex-wrap items-center gap-3"> |
| 249 | <KindSelect /> |
| 250 | <label className="flex items-center gap-2 text-xs text-muted"> |
| 251 | <Switch name="pinned" value="true" size="sm" /> |
| 252 | Pin it |
| 253 | </label> |
| 254 | <span className="grow" /> |
| 255 | <button |
| 256 | type="submit" |
| 257 | disabled={fetcher.state !== "idle"} |
| 258 | className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50" |
| 259 | > |
| 260 | {fetcher.state !== "idle" ? "Adding…" : "Add"} |
| 261 | </button> |
| 262 | </div> |
| 263 | {fetcher.data?.error && <p className="mt-2 text-sm text-danger">{fetcher.data.error}</p>} |
| 264 | <p className="mt-2 text-xs text-faint"> |
| 265 | One fact, convention, decision or gotcha each. Never a secret: say where it lives instead, and |
| 266 | g1t refuses anything that looks like a key or a token. |
| 267 | </p> |
| 268 | </fetcher.Form> |
| 269 | ); |
| 270 | } |
| 271 | |
| 272 | type MemoryApi = { |
| 273 | addMemory(actor: Actor, workspace: string, memory: { scope: MemoryScope; repo?: { namespace: string; name: string } | null; text: string; kind?: MemoryKind; pinned?: boolean }): Promise<Result<Memory>>; |
| 274 | updateMemory(actor: Actor, workspace: string, id: string, change: { text?: string; kind?: MemoryKind; pinned?: boolean }): Promise<Result<Memory>>; |
| 275 | deleteMemory(actor: Actor, workspace: string, id: string): Promise<Result<boolean>>; |
| 276 | }; |
| 277 | |
| 278 | /** The action both memory pages share: add, update and delete. */ |
| 279 | export async function memoryAction( |
| 280 | api: MemoryApi, |
| 281 | actor: Actor, |
| 282 | workspace: string, |
| 283 | repo: { namespace: string; name: string } | null, |
| 284 | form: FormData, |
| 285 | ): Promise<Done> { |
| 286 | const intent = String(form.get("intent") ?? ""); |
| 287 | const kindValue = String(form.get("kind") ?? ""); |
| 288 | const kind = MEMORY_KINDS.includes(kindValue as MemoryKind) ? (kindValue as MemoryKind) : undefined; |
| 289 | if (intent === "add") { |
| 290 | const scope: MemoryScope = form.get("scope") === "workspace" ? "workspace" : "project"; |
| 291 | const added = await api.addMemory(actor, workspace, { |
| 292 | scope, |
| 293 | repo, |
| 294 | text: String(form.get("text") ?? ""), |
| 295 | kind, |
| 296 | pinned: form.get("pinned") === "true", |
| 297 | }); |
| 298 | return added.ok ? { ok: true } : { ok: false, error: added.error.message }; |
| 299 | } |
| 300 | if (intent === "update") { |
| 301 | const pinned = form.get("pinned"); |
| 302 | const text = form.get("text"); |
| 303 | const changed = await api.updateMemory(actor, workspace, String(form.get("id") ?? ""), { |
| 304 | text: text == null ? undefined : String(text), |
| 305 | kind, |
| 306 | pinned: pinned == null ? undefined : pinned === "true", |
| 307 | }); |
| 308 | return changed.ok ? { ok: true } : { ok: false, error: changed.error.message }; |
| 309 | } |
| 310 | if (intent === "delete") { |
| 311 | const gone = await api.deleteMemory(actor, workspace, String(form.get("id") ?? "")); |
| 312 | return gone.ok ? { ok: true } : { ok: false, error: gone.error.message }; |
| 313 | } |
| 314 | return { ok: false, error: "Unknown action." }; |
| 315 | } |