g1t/apps/web/app/components/memory.tsx

308 lines12,393 bytesCodeBlame

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 domains1/**
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
Merge branch 'worktree-agent-a985c01f43538af01'12import { SubmitButton, TimeAgo } from "./ui";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains13import {
14 AlertDialog,
15 AlertDialogAction,
16 AlertDialogCancel,
17 AlertDialogContent,
18 AlertDialogDescription,
19 AlertDialogFooter,
20 AlertDialogHeader,
21 AlertDialogTitle,
22 AlertDialogTrigger,
23} from "./ui/alert-dialog";
24import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
25import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
26import { Switch } from "./ui/switch";
27
28const 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
35const 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
38type Done = { ok: boolean; error?: string } | undefined;
39
40function 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
48function 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. */
66function 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
108function 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} />
Merge branch 'worktree-agent-a985c01f43538af01'130 <SubmitButton fetcher={fetcher} pending="Saving…">
Agents and memory, checks and conflicts, profiles, slug renames, custom domains131 Save
Merge branch 'worktree-agent-a985c01f43538af01'132 </SubmitButton>
Agents and memory, checks and conflicts, profiles, slug renames, custom domains133 </div>
134 {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>}
135 </fetcher.Form>
136 </DialogContent>
137 </Dialog>
138 );
139}
140
141function MemoryItem({ memory, action, editable }: { memory: Memory; action: string; editable: boolean }) {
142 const fetcher = useFetcher<Done>();
143 const pinned = fetcher.formData ? fetcher.formData.get("pinned") === "true" : memory.pinned;
144 const gone = fetcher.formData?.get("intent") === "delete";
145 if (gone) return null;
146 return (
147 <li className="group flex gap-3 px-4 py-3">
148 <div className="min-w-0 grow">
149 <div className="flex flex-wrap items-center gap-2">
150 <KindBadge kind={memory.kind} />
151 {pinned && (
152 <span className="inline-flex items-center gap-1 text-[0.6875rem] text-accent">
153 <Pin size={11} />
154 pinned
155 </span>
156 )}
157 </div>
158 <p className="mt-1.5 text-sm whitespace-pre-wrap">{memory.text}</p>
159 <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-faint">
160 <Source memory={memory} />
161 <span>
162 added <TimeAgo at={memory.createdAt} />
163 </span>
164 <span>{memory.lastUsedAt ? <>given to an agent <TimeAgo at={memory.lastUsedAt} /></> : "not given to an agent yet"}</span>
165 </p>
166 {fetcher.data?.error && <p className="mt-1.5 text-xs text-danger">{fetcher.data.error}</p>}
167 </div>
168 {editable && (
169 <div className="flex shrink-0 items-start gap-0.5">
170 <button
171 type="button"
172 aria-label={pinned ? "Unpin" : "Pin"}
173 title={pinned ? "Unpin" : "Pin: given to every agent first"}
174 onClick={() => fetcher.submit({ intent: "update", id: memory.id, pinned: String(!pinned) }, { method: "post", action })}
Merge branch 'worktree-agent-a985c01f43538af01'175 disabled={fetcher.state !== "idle"}
176 className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg disabled:opacity-50"
Agents and memory, checks and conflicts, profiles, slug renames, custom domains177 >
178 {pinned ? <PinOff size={13} /> : <Pin size={13} />}
179 </button>
180 <EditMemory memory={memory} action={action} />
181 <AlertDialog>
182 <AlertDialogTrigger aria-label="Forget" className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-danger">
183 <Trash2 size={13} />
184 </AlertDialogTrigger>
185 <AlertDialogContent>
186 <AlertDialogHeader>
187 <AlertDialogTitle>Forget this?</AlertDialogTitle>
188 <AlertDialogDescription>
189 No agent is given it again. This cannot be undone.
190 </AlertDialogDescription>
191 </AlertDialogHeader>
192 <AlertDialogFooter>
193 <AlertDialogCancel>Keep it</AlertDialogCancel>
194 <AlertDialogAction onClick={() => fetcher.submit({ intent: "delete", id: memory.id }, { method: "post", action })}>
195 Forget
196 </AlertDialogAction>
197 </AlertDialogFooter>
198 </AlertDialogContent>
199 </AlertDialog>
200 </div>
201 )}
202 </li>
203 );
204}
205
206export function MemoryList({
207 memories,
208 action,
209 editable,
210 empty,
211}: {
212 memories: Memory[];
213 action: string;
214 editable: boolean;
215 empty: string;
216}) {
217 if (memories.length === 0) {
218 return <p className="rounded-xl border border-dashed border-line px-4 py-6 text-sm text-muted">{empty}</p>;
219 }
220 return (
221 <ul className="divide-y divide-line rounded-xl border border-line bg-surface">
222 {memories.map((memory) => (
223 <MemoryItem key={memory.id} memory={memory} action={action} editable={editable} />
224 ))}
225 </ul>
226 );
227}
228
229/** A form to add a memory at one level. */
230export function AddMemory({ scope, action, placeholder }: { scope: MemoryScope; action: string; placeholder: string }) {
231 const fetcher = useFetcher<Done>();
232 const form = useRef<HTMLFormElement>(null);
233 useEffect(() => {
234 if (fetcher.state === "idle" && fetcher.data?.ok) form.current?.reset();
235 }, [fetcher.state, fetcher.data]);
236 return (
237 <fetcher.Form ref={form} method="post" action={action} className="rounded-xl border border-line bg-surface p-4">
238 <input type="hidden" name="intent" value="add" />
239 <input type="hidden" name="scope" value={scope} />
240 <label className="flex items-center gap-2 text-sm font-medium">
241 <Brain size={15} className="text-merged" />
242 Add to {scope === "workspace" ? "the workspace's" : "this project's"} memory
243 </label>
244 <textarea name="text" required rows={2} maxLength={1000} placeholder={placeholder} className={`${TEXTAREA} mt-3`} />
245 <div className="mt-3 flex flex-wrap items-center gap-3">
246 <KindSelect />
247 <label className="flex items-center gap-2 text-xs text-muted">
248 <Switch name="pinned" value="true" size="sm" />
249 Pin it
250 </label>
251 <span className="grow" />
Merge branch 'worktree-agent-a985c01f43538af01'252 <SubmitButton fetcher={fetcher} pending="Adding…">
253 Add
254 </SubmitButton>
Agents and memory, checks and conflicts, profiles, slug renames, custom domains255 </div>
256 {fetcher.data?.error && <p className="mt-2 text-sm text-danger">{fetcher.data.error}</p>}
257 <p className="mt-2 text-xs text-faint">
258 One fact, convention, decision or gotcha each. Never a secret: say where it lives instead, and
259 g1t refuses anything that looks like a key or a token.
260 </p>
261 </fetcher.Form>
262 );
263}
264
265type MemoryApi = {
266 addMemory(actor: Actor, workspace: string, memory: { scope: MemoryScope; repo?: { namespace: string; name: string } | null; text: string; kind?: MemoryKind; pinned?: boolean }): Promise<Result<Memory>>;
267 updateMemory(actor: Actor, workspace: string, id: string, change: { text?: string; kind?: MemoryKind; pinned?: boolean }): Promise<Result<Memory>>;
268 deleteMemory(actor: Actor, workspace: string, id: string): Promise<Result<boolean>>;
269};
270
271/** The action both memory pages share: add, update and delete. */
272export async function memoryAction(
273 api: MemoryApi,
274 actor: Actor,
275 workspace: string,
276 repo: { namespace: string; name: string } | null,
277 form: FormData,
278): Promise<Done> {
279 const intent = String(form.get("intent") ?? "");
280 const kindValue = String(form.get("kind") ?? "");
281 const kind = MEMORY_KINDS.includes(kindValue as MemoryKind) ? (kindValue as MemoryKind) : undefined;
282 if (intent === "add") {
283 const scope: MemoryScope = form.get("scope") === "workspace" ? "workspace" : "project";
284 const added = await api.addMemory(actor, workspace, {
285 scope,
286 repo,
287 text: String(form.get("text") ?? ""),
288 kind,
289 pinned: form.get("pinned") === "true",
290 });
291 return added.ok ? { ok: true } : { ok: false, error: added.error.message };
292 }
293 if (intent === "update") {
294 const pinned = form.get("pinned");
295 const text = form.get("text");
296 const changed = await api.updateMemory(actor, workspace, String(form.get("id") ?? ""), {
297 text: text == null ? undefined : String(text),
298 kind,
299 pinned: pinned == null ? undefined : pinned === "true",
300 });
301 return changed.ok ? { ok: true } : { ok: false, error: changed.error.message };
302 }
303 if (intent === "delete") {
304 const gone = await api.deleteMemory(actor, workspace, String(form.get("id") ?? ""));
305 return gone.ok ? { ok: true } : { ok: false, error: gone.error.message };
306 }
307 return { ok: false, error: "Unknown action." };
308}

This file's history is long; its oldest lines are credited to the oldest commit read.