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