Skip to content
313 linesCodeBlameRaw
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 */
7import { Bot, Brain, Pencil, Pin, PinOff, Trash2, User } from "lucide-react";
8import { useEffect, useRef, useState } from "react";
9import { Link, useFetcher } from "react-router";
10
11import { MEMORY_KINDS, type Memory, type MemoryKind, type MemoryScope, type Result, type User as Actor } from "@g1t/contracts";
12
13import { SubmitButton, TimeAgo } from "./ui";
14import {
15 AlertDialog,
16 AlertDialogAction,
17 AlertDialogCancel,
18 AlertDialogContent,
19 AlertDialogDescription,
20 AlertDialogFooter,
21 AlertDialogHeader,
22 AlertDialogTitle,
23 AlertDialogTrigger,
24} from "./ui/alert-dialog";
25import { Button } from "./ui/button";
26import { Card } from "./ui/card";
27import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
28import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
29import { Hint } from "./ui/hint";
30import { Switch } from "./ui/switch";
31
32const 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
39const 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
42type Done = { ok: boolean; error?: string } | undefined;
43
44function 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
52function 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. */
70function 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
106function 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
139function 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
207export 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. */
233export 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
270type 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. */
277export 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}