AkurAI Build
Menu

popagent

public

Latest change 6edc7e928c57dab23c0973ed3f7ee70b3ebb72d5 - Initial popagent baseline by AkurAI Build

import { Brain, Pencil, Plus, Search, Trash2, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { MemoryListResponse, MemoryRecord } from "../api-types";
import { apiFetch } from "./api";
import { toast } from "./toast";
import { useFocusTrap } from "./use-focus-trap";
export function MemoryDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
  const [memories, setMemories] = useState<MemoryRecord[]>([]);
  const [query, setQuery] = useState("");
  const [kind, setKind] = useState<"" | "fact" | "episode">("");
  const [editing, setEditing] = useState<MemoryRecord>();
  const [creating, setCreating] = useState(false);
  const [content, setContent] = useState("");
  const [key, setKey] = useState("");
  const [importance, setImportance] = useState(0.8);
  const closeRef = useRef<HTMLButtonElement>(null);
  const drawerRef = useRef<HTMLElement>(null);

  const load = useCallback(async () => {
    const params = new URLSearchParams();
    if (query.trim()) params.set("query", query.trim());
    if (kind) params.set("kind", kind);
    const response = await apiFetch(`/api/memories?${params}`);
    if (response.ok) setMemories(((await response.json()) as MemoryListResponse).memories);
    else toast("Unable to load memories");
  }, [kind, query]);

  useEffect(() => {
    if (open) void load();
  }, [load, open]);

  useEffect(() => {
    if (!open) return;
    closeRef.current?.focus();
    const close = (event: KeyboardEvent) => {
      if (event.key === "Escape") onClose();
    };
    document.addEventListener("keydown", close);
    return () => document.removeEventListener("keydown", close);
  }, [onClose, open]);

  const beginEdit = (memory: MemoryRecord) => {
    setEditing(memory);
    setCreating(false);
    setContent(memory.content);
    setKey(memory.key ?? "");
    setImportance(memory.importance);
  };
  const beginCreate = () => {
    setEditing(undefined);
    setCreating(true);
    setContent("");
    setKey("");
    setImportance(0.8);
  };
  const save = async () => {
    const response = await apiFetch(editing ? `/api/memories/${editing.id}` : "/api/memories", {
      method: editing ? "PATCH" : "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ content, key: key || undefined, importance }),
    });
    if (response.ok) {
      setEditing(undefined);
      setCreating(false);
      await load();
    } else toast("Unable to save the memory");
  };
  const remove = async (memory: MemoryRecord) => {
    if (!confirm(`Delete this ${memory.kind}? This cannot be undone.`)) return;
    const response = await apiFetch(`/api/memories/${memory.id}`, { method: "DELETE" });
    if (response.ok) await load();
    else toast("Unable to delete the memory");
  };

  useFocusTrap(drawerRef, open);
  if (!open) return null;
  return (
    <aside ref={drawerRef} role="dialog" aria-modal="true" aria-labelledby="memory-title" className="fixed inset-y-0 right-0 z-40 flex w-full max-w-md flex-col border-l border-line bg-panel shadow-2xl">
      <div className="flex items-center justify-between border-b border-line px-4 py-3"><div id="memory-title" className="flex items-center gap-2 font-semibold"><Brain className="size-4 text-accent" />Long-term memory</div><button ref={closeRef} type="button" aria-label="Close memory" onClick={onClose} className="flex size-11 items-center justify-center rounded hover:bg-panel-2"><X className="size-4" /></button></div>
      <div className="space-y-2 border-b border-line p-3"><div className="flex gap-2"><label className="flex flex-1 items-center gap-2 rounded border border-line bg-panel-2 px-2"><Search className="size-3.5 text-ink-dim" /><input aria-label="Search memories" value={query} onChange={(event) => setQuery(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Enter") void load(); }} placeholder="Search memories" className="min-w-0 flex-1 bg-transparent py-2 text-sm outline-none" /></label><select aria-label="Memory type" value={kind} onChange={(event) => setKind(event.currentTarget.value as typeof kind)} className="rounded border border-line bg-panel-2 px-2 text-xs"><option value="">All</option><option value="fact">Facts</option><option value="episode">Episodes</option></select><button type="button" aria-label="Create fact" onClick={beginCreate} className="flex size-11 items-center justify-center rounded bg-accent text-white"><Plus className="size-4" /></button></div></div>
      {(editing || creating) ? <div className="space-y-2 border-b border-line bg-panel-2 p-3"><input aria-label="Memory key" value={key} onChange={(event) => setKey(event.currentTarget.value)} placeholder="Semantic key (optional)" className="w-full rounded border border-line bg-bg px-3 py-2 text-sm outline-none" /><textarea aria-label="Memory content" value={content} maxLength={2000} onChange={(event) => setContent(event.currentTarget.value)} rows={5} placeholder="Durable fact" className="w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm outline-none" /><label className="flex items-center gap-3 text-xs text-ink-dim">Importance <input aria-label="Importance" type="range" min="0" max="1" step="0.05" value={importance} onChange={(event) => setImportance(Number(event.currentTarget.value))} className="flex-1 accent-accent" /><span>{Math.round(importance * 100)}%</span></label><div className="flex justify-end gap-2"><button type="button" onClick={() => { setEditing(undefined); setCreating(false); }} className="min-h-11 rounded px-3 text-xs text-ink-dim">Cancel</button><button type="button" disabled={!content.trim()} onClick={() => void save()} className="min-h-11 rounded bg-accent px-4 text-xs text-white disabled:opacity-50">Save fact</button></div></div> : null}
      <div className="min-h-0 flex-1 overflow-y-auto p-3">{memories.map((memory) => <article key={memory.id} className="mb-2 rounded border border-line bg-panel-2 p-3"><div className="mb-2 flex items-center gap-2"><span className={`rounded-sm px-2 py-0.5 text-[10px] uppercase ${memory.kind === "fact" ? "bg-accent-soft text-accent" : "bg-bg text-ink-dim"}`}>{memory.kind}</span>{memory.key ? <code className="truncate text-[10px] text-ink-dim">{memory.key}</code> : null}<span className="ml-auto text-[10px] text-ink-dim">{Math.round(memory.importance * 100)}%</span></div><p className="whitespace-pre-wrap text-sm leading-relaxed text-ink">{memory.content}</p><div className="mt-2 flex items-center text-[10px] text-ink-dim"><span>{new Date(memory.updatedAt).toLocaleString()} · recalled {memory.accessCount}×</span><span className="ml-auto flex gap-1">{memory.kind === "fact" ? <button type="button" aria-label="Edit fact" onClick={() => beginEdit(memory)} className="flex size-8 items-center justify-center rounded hover:bg-bg"><Pencil className="size-3" /></button> : null}<button type="button" aria-label={`Delete ${memory.kind}`} onClick={() => void remove(memory)} className="flex size-8 items-center justify-center rounded text-red-400 hover:bg-bg"><Trash2 className="size-3" /></button></span></div></article>)}</div>
    </aside>
  );
}