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