Inline 'Updated memory' pill that expands into a persistent memory drawer: inspect workspace rules and user preferences learned by the agent, add new rules, or forget entries.
Set your access key from API keys as NEXTUI_TOKEN in .env.local and add the @nextui namespace below to components.json once. Then every item installs by name. Each install counts as a copy on a free account.
$pnpm dlx shadcn@latest add @nextui/agent-memory
1{2"registries": {3"@nextui": {4"url": "https://nextui.dev/r/{name}.json",5"headers": { "Authorization": "Bearer ${NEXTUI_TOKEN}" }6}7}8}
1"use client";23import { useState } from "react";4import { AgentMemory, type MemoryEntry } from "@/components/nextui/agent-memory";56export function MemoryDrawer({ initial }: { initial: MemoryEntry[] }) {7const [entries, setEntries] = useState(initial);89return (10<AgentMemory11entries={entries}12onAdd={(fact) =>13setEntries((list) => [{ id: crypto.randomUUID(), fact, scope: "user", justAdded: true }, ...list])14}15onRemove={(id) => setEntries((list) => list.filter((m) => m.id !== id))}16/>17);18}
| Prop | Type | Default | Description |
|---|---|---|---|
| entries | MemoryEntry[] | – | Saved memory items with id, fact, scope (workspace, user, session) and justAdded |
| latestNotice | string | – | Inline summary line shown in the header bar |
| defaultOpen | boolean | true | Whether the memory drawer starts expanded |
| onAdd | (fact: string) => void | – | Called when the user submits a new memory rule |
| onRemove | (id: string) => void | – | Called when the user deletes a memory entry |
| onEdit | (id: string, fact: string) => void | – | Called when a fact is edited inline |
| autoPlay | boolean | false | Loop a scripted memory-capture animation |
| className | string | – | Extra classes on the wrapper |
"use client";
import { useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import { BookmarkCheck, Brain, Check, ChevronDown, Pencil, Plus, Trash2, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type MemoryEntry = {
id: string;
fact: string;
scope?: "workspace" | "user" | "session";
justAdded?: boolean;
};
export type AgentMemoryProps = {
entries?: MemoryEntry[];
/** Fact shown in the "Saved to memory" chip. Leave unset to hide the chip. */
latestNotice?: string;
defaultOpen?: boolean;
onAdd?: (fact: string) => void;
onEdit?: (id: string, fact: string) => void;
onRemove?: (id: string) => void;
/** Loop a scripted save → edit → forget demo. */
autoPlay?: boolean;
className?: string;
};
const INITIAL: MemoryEntry[] = [
{ id: "m1", fact: "Always use pnpm, never npm or yarn", scope: "workspace" },
{ id: "m2", fact: "Prefer server components; use client only for interactivity", scope: "workspace" },
{ id: "m3", fact: "Code style: TypeScript strict", scope: "user" },
];
const NEW_FACT: MemoryEntry = { id: "m4", fact: "Run vitest --run before opening a PR", scope: "workspace" };
const EDITED = "Code style: TypeScript strict, no implicit any";
const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 9600;
function useDemoClock(enabled: boolean, loop: number) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el || !enabled) return;
const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, [enabled]);
useEffect(() => {
if (!enabled || !visible) return;
const id = setInterval(() => setElapsed((e) => e + TICK), TICK);
return () => clearInterval(id);
}, [enabled, visible]);
return { ref, t: elapsed % loop };
}
type Chip = null | "saving" | "saved";
type View = {
entries: MemoryEntry[];
chip: Chip;
chipText: string;
editingId: string | null;
editDraft: string;
flashId: string | null;
armedId: string | null;
};
function demoView(t: number): View {
let entries = INITIAL.map((e) => ({ ...e }));
if (t >= 1700) entries = [{ ...NEW_FACT }, ...entries];
const typing = Math.max(0, Math.min(1, (t - 4000) / 1100));
const base = INITIAL[2].fact;
const editDraft = base + EDITED.slice(base.length, base.length + Math.round((EDITED.length - base.length) * typing));
if (t >= 5700) entries = entries.map((e) => (e.id === "m3" ? { ...e, fact: EDITED } : e));
if (t >= 7300) entries = entries.filter((e) => e.id !== "m2");
return {
entries,
chip: t >= 800 && t < 1700 ? "saving" : t >= 1700 && t < 3400 ? "saved" : null,
chipText: NEW_FACT.fact,
editingId: t >= 3600 && t < 5700 ? "m3" : null,
editDraft,
flashId: t >= 1700 && t < 3400 ? "m4" : t >= 5700 && t < 6800 ? "m3" : null,
armedId: t >= 6600 && t < 7300 ? "m2" : null,
};
}
/** Long-term agent memory: a "Saved to memory" chip plus a drawer to review, edit inline and forget facts. */
export function AgentMemory({
entries: controlledEntries,
latestNotice,
defaultOpen = true,
onAdd,
onEdit,
onRemove,
autoPlay = false,
className,
}: AgentMemoryProps) {
const reduce = useReducedMotion() ?? false;
const [open, setOpen] = useState(defaultOpen);
const [draft, setDraft] = useState("");
const [touched, setTouched] = useState(false);
const [view, setView] = useState<View>({
entries: [NEW_FACT, ...INITIAL],
chip: latestNotice ? "saved" : null,
chipText: latestNotice ?? "",
editingId: null,
editDraft: "",
flashId: null,
armedId: null,
});
const demo = autoPlay && !reduce && !touched && !controlledEntries;
const { ref, t } = useDemoClock(demo, LOOP);
// Sync the scripted frame during render (no effect → no extra commit per tick).
const [syncedT, setSyncedT] = useState(-1);
if (demo && t !== syncedT) {
setSyncedT(t);
setView(demoView(t));
}
const entries = controlledEntries ?? view.entries;
const patch = (p: Partial<View>) => {
setTouched(true);
setView((v) => ({ ...v, ...p }));
};
const add = (e: FormEvent) => {
e.preventDefault();
const fact = draft.trim();
if (!fact) return;
onAdd?.(fact);
const id = `m-${Date.now()}`;
setTouched(true);
setView((v) => ({
...v,
entries: controlledEntries ? v.entries : [{ id, fact, scope: "user" }, ...v.entries],
chip: "saved",
chipText: fact,
flashId: id,
}));
setDraft("");
};
const commitEdit = () => {
const id = view.editingId;
const fact = view.editDraft.trim();
if (!id) return;
if (fact) onEdit?.(id, fact);
patch({
editingId: null,
flashId: fact ? id : null,
entries: controlledEntries || !fact ? view.entries : view.entries.map((e) => (e.id === id ? { ...e, fact } : e)),
});
};
const remove = (id: string) => {
onRemove?.(id);
patch({ entries: controlledEntries ? view.entries : view.entries.filter((e) => e.id !== id), armedId: null });
};
const onEditKey = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
commitEdit();
} else if (e.key === "Escape") patch({ editingId: null });
};
return (
<div ref={ref} className={cn("w-full max-w-md text-sm", className)}>
{/* Saved-to-memory chip */}
<div className="mb-2 flex h-8 justify-center">
<AnimatePresence>
{view.chip && (
<motion.div
key="chip"
role="status"
initial={reduce ? false : { opacity: 0, y: 10, scale: 0.92 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? undefined : { opacity: 0, y: -6, scale: 0.96 }}
transition={{ type: "spring", stiffness: 420, damping: 30 }}
className="relative inline-flex max-w-full items-center gap-2 overflow-hidden rounded-full border bg-card py-1 pr-3 pl-1 text-xs shadow-[0_6px_20px_-10px_rgb(0_0_0/0.35)]"
>
<span className="relative grid size-6 shrink-0 place-items-center rounded-full bg-foreground text-background">
<AnimatePresence mode="popLayout" initial={false}>
{view.chip === "saving" ? (
<motion.svg key="ring" viewBox="0 0 24 24" className="size-4" exit={{ opacity: 0, scale: 0.5 }} aria-hidden>
<circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" strokeOpacity={0.25} strokeWidth="2.5" />
<motion.circle
cx="12"
cy="12"
r="8"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 0.9 }}
transition={{ duration: 0.9, ease: "easeOut" }}
style={{ rotate: -90, transformOrigin: "center" }}
/>
</motion.svg>
) : (
<motion.span
key="check"
initial={reduce ? false : { scale: 0.3, rotate: -30 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 600, damping: 16 }}
>
<BookmarkCheck className="size-3.5" aria-hidden />
</motion.span>
)}
</AnimatePresence>
</span>
<span className="shrink-0 font-medium">{view.chip === "saving" ? "Saving to memory…" : "Saved to memory"}</span>
<span className="truncate text-muted-foreground">“{view.chipText}”</span>
{view.chip === "saved" && !reduce && <span className="mem-shine pointer-events-none absolute inset-0" aria-hidden />}
</motion.div>
)}
</AnimatePresence>
</div>
<div className="overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-18px_rgb(0_0_0/0.25)]">
<button
type="button"
aria-expanded={open}
onClick={() => {
setTouched(true);
setOpen((v) => !v);
}}
className="flex w-full cursor-pointer items-center gap-3 bg-gradient-to-b from-muted/50 to-transparent px-4 py-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<span className="grid size-8 shrink-0 place-items-center rounded-lg border bg-background shadow-xs">
<Brain className="size-4" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block font-medium leading-tight">Memory</span>
<span className="mt-0.5 block text-xs text-muted-foreground">Facts the agent keeps across sessions</span>
</span>
<motion.span
key={entries.length}
initial={reduce ? false : { scale: 1.35 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 600, damping: 18 }}
className="rounded-full border bg-background px-2 font-mono text-[11px] tabular-nums"
>
{entries.length}
</motion.span>
<ChevronDown className={cn("size-4 shrink-0 text-muted-foreground transition-transform duration-300", open && "rotate-180")} aria-hidden />
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={reduce ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? undefined : { height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: EASE }}
className="overflow-hidden border-t"
>
<ul className="px-2 py-1.5">
<AnimatePresence initial={false}>
{entries.map((item) => {
const editing = view.editingId === item.id;
const armed = view.armedId === item.id;
return (
<motion.li
key={item.id}
layout={!reduce}
initial={reduce ? false : { opacity: 0, height: 0, y: -8 }}
animate={{ opacity: 1, height: "auto", y: 0 }}
exit={reduce ? undefined : { opacity: 0, height: 0, x: -24, transition: { duration: 0.3, ease: EASE } }}
transition={{ duration: 0.4, ease: EASE }}
className="overflow-hidden"
>
<div
className={cn(
"group relative flex min-h-10 items-center gap-2.5 rounded-lg px-2 py-1.5 text-xs transition-colors duration-300",
editing ? "bg-muted/70" : "hover:bg-muted/50",
armed && "bg-red-500/[0.07]",
)}
>
{view.flashId === item.id && !reduce && <span className="mem-flash pointer-events-none absolute inset-0 rounded-lg" aria-hidden />}
<span
className={cn(
"relative size-1.5 shrink-0 rounded-full transition-colors",
view.flashId === item.id ? "bg-foreground" : "bg-muted-foreground/40",
)}
/>
{editing ? (
<input
autoFocus={touched}
aria-label="Edit memory"
value={view.editDraft}
onChange={(e) => patch({ editDraft: e.target.value })}
onKeyDown={onEditKey}
className="relative h-7 min-w-0 flex-1 rounded-md border border-foreground/25 bg-background px-2 text-xs shadow-xs outline-none focus:border-foreground/50"
/>
) : (
<span className="relative min-w-0 flex-1 truncate">{item.fact}</span>
)}
{!editing && item.scope && (
<span className="relative shrink-0 rounded-full border bg-background px-1.5 font-mono text-[10px] text-muted-foreground transition-opacity group-hover:opacity-0">
{item.scope}
</span>
)}
<div
className={cn(
"flex shrink-0 items-center gap-0.5",
editing || armed ? "relative" : "absolute right-2 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100",
)}
>
{editing ? (
<>
<IconButton label="Save memory" onClick={commitEdit}>
<Check className="size-3.5" aria-hidden />
</IconButton>
<IconButton label="Cancel edit" onClick={() => patch({ editingId: null })}>
<X className="size-3.5" aria-hidden />
</IconButton>
</>
) : (
<>
<IconButton label={`Edit: ${item.fact}`} onClick={() => patch({ editingId: item.id, editDraft: item.fact })}>
<Pencil className="size-3" aria-hidden />
</IconButton>
<IconButton label={`Forget: ${item.fact}`} onClick={() => remove(item.id)} danger armed={armed}>
<Trash2 className="size-3" aria-hidden />
</IconButton>
</>
)}
</div>
</div>
</motion.li>
);
})}
</AnimatePresence>
</ul>
<form onSubmit={add} className="flex items-center gap-2 border-t px-4 py-2.5">
<Plus className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Teach the agent a rule or preference…"
aria-label="New memory"
className="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
/>
<button
type="submit"
disabled={!draft.trim()}
className="h-7 shrink-0 cursor-pointer rounded-md bg-foreground px-2.5 text-[11px] font-medium text-background transition-opacity outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-30"
>
Save
</button>
</form>
</motion.div>
)}
</AnimatePresence>
</div>
<style>{`
.mem-flash { background: color-mix(in oklab, var(--foreground) 9%, transparent); animation: mem-flash 1.6s ease-out forwards; }
@keyframes mem-flash { from { opacity: 1; } to { opacity: 0; } }
.mem-shine { background: linear-gradient(100deg, transparent 35%, color-mix(in oklab, var(--foreground) 10%, transparent) 50%, transparent 65%); background-size: 250% 100%; animation: mem-shine 1.1s ease-out 1 forwards; }
@keyframes mem-shine { from { background-position: 130% 0; } to { background-position: -30% 0; } }
`}</style>
</div>
);
}
function IconButton({
label,
onClick,
children,
danger,
armed,
}: {
label: string;
onClick: () => void;
children: React.ReactNode;
danger?: boolean;
armed?: boolean;
}) {
return (
<motion.button
type="button"
aria-label={label}
title={label}
onClick={onClick}
animate={{ scale: armed ? [1, 0.85, 1] : 1 }}
transition={{ duration: 0.4 }}
className={cn(
"grid size-6 cursor-pointer place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
danger && "hover:text-red-600 dark:hover:text-red-400",
armed && "bg-red-500/10 text-red-600 dark:text-red-400",
)}
>
{children}
</motion.button>
);
}
export default function AgentMemoryDemo() {
return (
<div className="flex h-[360px] w-full items-start justify-center pt-1">
<AgentMemory autoPlay />
</div>
);
}