g1t/apps/web/app/components/memory.tsx
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.
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 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 | } |