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