AI Patterns

Memory PanelNew

A panel listing what the assistant remembers about the user — edit or forget each memory with undo, switch memory off, forget all — plus a memory-updated notice for the chat.

JobTrust
Seen in

Memory

Details the assistant remembers about you to personalise answers.

  • Works as a product designer at a fintech startup.

    From a chat on 12 Oct

  • Prefers concise answers with bullet points.

    You asked me to remember this

  • Writes in British English.

    From a chat on 3 Nov

3 memories
"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { Brain, Check, Pencil, Trash2, X } from "lucide-react";

import { cn } from "@/lib/utils";

export interface Memory {
  id: string;
  text: string;
  /** Where it was learned, already formatted ("From a chat on 12 Oct"). */
  source?: string;
}

export interface MemoryPanelProps {
  memories: Memory[];
  /** Whether the AI may save and use memories at all. */
  enabled: boolean;
  onEnabledChange: (enabled: boolean) => void;
  onEdit?: (id: string, text: string) => void;
  /** Called once the undo window has passed, so the deletion can be committed. */
  onDelete?: (id: string) => void;
  onClearAll?: () => void;
  title?: string;
  /** How long a deleted memory can be restored, in ms. */
  undoMs?: number;
  className?: string;
}

export function MemoryPanel({
  memories,
  enabled,
  onEnabledChange,
  onEdit,
  onDelete,
  onClearAll,
  title = "Memory",
  undoMs = 5000,
  className,
}: MemoryPanelProps) {
  const [editingId, setEditingId] = React.useState<string | null>(null);
  const [draft, setDraft] = React.useState("");
  const [pending, setPending] = React.useState<Memory | null>(null);
  const [confirmClear, setConfirmClear] = React.useState(false);
  const timer = React.useRef<number | undefined>(undefined);
  const onDeleteRef = React.useRef(onDelete);
  const titleId = React.useId();

  React.useEffect(() => {
    onDeleteRef.current = onDelete;
  }, [onDelete]);

  React.useEffect(() => () => window.clearTimeout(timer.current), []);

  const visible = memories.filter((m) => m.id !== pending?.id);

  function commitPending() {
    window.clearTimeout(timer.current);
    if (pending) onDeleteRef.current?.(pending.id);
  }

  function remove(memory: Memory) {
    // Deleting another memory commits the previous one first: one undo at a time.
    commitPending();
    setPending(memory);
    timer.current = window.setTimeout(() => {
      onDeleteRef.current?.(memory.id);
      setPending(null);
    }, undoMs);
  }

  function undo() {
    window.clearTimeout(timer.current);
    setPending(null);
  }

  function startEdit(memory: Memory) {
    setEditingId(memory.id);
    setDraft(memory.text);
  }

  function saveEdit() {
    const text = draft.trim();
    if (editingId && text) onEdit?.(editingId, text);
    setEditingId(null);
  }

  return (
    <section
      aria-labelledby={titleId}
      className={cn("flex w-full flex-col overflow-hidden rounded-2xl border bg-card", className)}
    >
      <header className="flex items-start gap-3 border-b px-4 py-3">
        <div className="min-w-0 flex-1">
          <h3 id={titleId} className="text-sm font-semibold">
            {title}
          </h3>
          <p className="mt-0.5 text-xs text-pretty text-muted-foreground">
            {enabled
              ? "Details the assistant remembers about you to personalise answers."
              : "Memory is off. Nothing new is saved, and saved memories aren't used."}
          </p>
        </div>
        <button
          type="button"
          role="switch"
          aria-checked={enabled}
          aria-label="Use memory"
          onClick={() => onEnabledChange(!enabled)}
          className={cn(
            "relative mt-0.5 inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
            enabled ? "bg-foreground" : "bg-muted-foreground/30"
          )}
        >
          <motion.span
            layout
            transition={{ type: "spring", stiffness: 500, damping: 32 }}
            className={cn("size-4 rounded-full bg-background shadow-sm", enabled ? "ml-[18px]" : "ml-0.5")}
          />
        </button>
      </header>

      <ul className={cn("flex flex-col transition-opacity", !enabled && "opacity-50")} aria-label="Saved memories">
        <AnimatePresence initial={false}>
          {visible.map((memory) => (
            <motion.li
              key={memory.id}
              layout
              initial={{ opacity: 0, height: 0 }}
              animate={{ opacity: 1, height: "auto" }}
              exit={{ opacity: 0, height: 0 }}
              transition={{ duration: 0.2, ease: "easeOut" }}
              className="group overflow-hidden border-b last:border-b-0"
            >
              {editingId === memory.id ? (
                <form
                  className="flex flex-col gap-2 px-4 py-3"
                  onSubmit={(e) => {
                    e.preventDefault();
                    saveEdit();
                  }}
                >
                  <textarea
                    autoFocus
                    value={draft}
                    onChange={(e) => setDraft(e.target.value)}
                    onKeyDown={(e) => {
                      if (e.key === "Escape") setEditingId(null);
                      if (e.key === "Enter" && !e.shiftKey) {
                        e.preventDefault();
                        saveEdit();
                      }
                    }}
                    aria-label="Edit memory"
                    rows={2}
                    className="w-full resize-none rounded-lg border bg-background px-2.5 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
                  />
                  <div className="flex justify-end gap-1.5">
                    <IconButton label="Cancel" onClick={() => setEditingId(null)}>
                      <X className="size-3.5" aria-hidden />
                    </IconButton>
                    <IconButton label="Save" type="submit" primary>
                      <Check className="size-3.5" aria-hidden />
                    </IconButton>
                  </div>
                </form>
              ) : (
                <div className="flex items-start gap-3 px-4 py-3">
                  <div className="min-w-0 flex-1">
                    <p className="text-sm text-pretty">{memory.text}</p>
                    {memory.source && <p className="mt-0.5 text-xs text-muted-foreground">{memory.source}</p>}
                  </div>
                  <div className="flex shrink-0 gap-0.5 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 [@media(hover:hover)]:focus-within:opacity-100">
                    {onEdit && (
                      <IconButton label={`Edit memory: ${memory.text}`} onClick={() => startEdit(memory)}>
                        <Pencil className="size-3.5" aria-hidden />
                      </IconButton>
                    )}
                    <IconButton label={`Forget: ${memory.text}`} onClick={() => remove(memory)}>
                      <Trash2 className="size-3.5" aria-hidden />
                    </IconButton>
                  </div>
                </div>
              )}
            </motion.li>
          ))}
        </AnimatePresence>

        {visible.length === 0 && (
          <li className="flex flex-col items-center gap-1 px-4 py-8 text-center">
            <Brain className="size-5 text-muted-foreground" aria-hidden />
            <p className="text-sm font-medium">Nothing remembered yet</p>
            <p className="text-xs text-muted-foreground">Ask the assistant to remember something, like your role or tone.</p>
          </li>
        )}
      </ul>

      <footer className="flex min-h-11 items-center justify-between gap-3 border-t bg-muted/40 px-4 py-2">
        <AnimatePresence mode="wait" initial={false}>
          {pending ? (
            <motion.div
              key="undo"
              role="status"
              initial={{ opacity: 0, y: 4 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -4 }}
              transition={{ duration: 0.15 }}
              className="flex min-w-0 flex-1 items-center gap-3"
            >
              <span className="truncate text-xs text-muted-foreground">Memory forgotten</span>
              <button type="button" onClick={undo} className="shrink-0 text-xs font-medium underline-offset-2 hover:underline">
                Undo
              </button>
            </motion.div>
          ) : (
            <motion.span
              key="count"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              className="text-xs text-muted-foreground"
            >
              {visible.length} {visible.length === 1 ? "memory" : "memories"}
            </motion.span>
          )}
        </AnimatePresence>

        {onClearAll && visible.length > 0 && (
          <button
            type="button"
            onClick={() => {
              if (!confirmClear) return setConfirmClear(true);
              setConfirmClear(false);
              commitPending();
              setPending(null);
              onClearAll();
            }}
            onBlur={() => setConfirmClear(false)}
            className={cn(
              "shrink-0 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors",
              confirmClear ? "bg-destructive text-white" : "text-muted-foreground hover:bg-accent hover:text-foreground"
            )}
          >
            {confirmClear ? "Click again to forget all" : "Forget all"}
          </button>
        )}
      </footer>
    </section>
  );
}

/** A one-line notice for the chat, shown when the assistant saves something new. */
export function MemoryUpdatedNotice({
  text,
  onManage,
  className,
}: {
  text: string;
  onManage?: () => void;
  className?: string;
}) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 4 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.2, ease: "easeOut" }}
      role="status"
      className={cn("inline-flex max-w-full items-center gap-2 rounded-full border bg-muted/50 py-1 pr-1 pl-2.5 text-xs", className)}
    >
      <Brain className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
      <span className="min-w-0 truncate">
        <span className="text-muted-foreground">Memory updated · </span>
        {text}
      </span>
      {onManage && (
        <button
          type="button"
          onClick={onManage}
          className="shrink-0 rounded-full px-2 py-0.5 font-medium text-foreground transition-colors hover:bg-accent"
        >
          Manage
        </button>
      )}
    </motion.div>
  );
}

function IconButton({
  label,
  children,
  onClick,
  type = "button",
  primary,
}: {
  label: string;
  children: React.ReactNode;
  onClick?: () => void;
  type?: "button" | "submit";
  primary?: boolean;
}) {
  return (
    <button
      type={type}
      onClick={onClick}
      aria-label={label}
      title={label.split(":")[0]}
      className={cn(
        "flex size-7 items-center justify-center rounded-md transition-colors",
        primary ? "bg-foreground text-background hover:bg-foreground/90" : "text-muted-foreground hover:bg-accent hover:text-foreground"
      )}
    >
      {children}
    </button>
  );
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Memory Panel

## Summary
A settings panel listing what the assistant remembers about the user, where each memory can be edited or forgotten, with a master switch for memory and a "Forget all". A matching one-line notice in the chat tells the user when something new is saved. Together they make personalisation visible and reversible.

## When to use
- Assistants that save facts about the user across conversations (role, preferences, projects) and use them in later answers.
- Products where personalisation could surprise people — they should be able to see why an answer assumed something.
- Anywhere data-protection rules give users the right to see and delete what is stored about them.

## When not to use
- Context that only lives within one conversation — that is the chat history, not memory.
- Organisation-level knowledge (docs, connectors). Use Connector Panel for sources the AI can read.
- As the only control. Pair it with a temporary-chat option (Incognito Toggle) so people can avoid saving in the first place.

## Anatomy
- Header: title, a one-line explanation that changes when memory is off, and a master switch.
- Memory rows: the memory in plain language, an optional source line ("From a chat on 12 Oct"), and edit and forget actions.
- Inline editor: a textarea replacing the row, with Save and Cancel.
- Empty state: what memory is for and how to add one.
- Footer: a count, an Undo affordance after a deletion, and a two-step "Forget all".
- Memory-updated notice: a pill in the chat ("Memory updated · …") with a Manage link to the panel.

## Behavior
- Forgetting a memory removes it at once and shows Undo for a few seconds; the deletion is committed when that window passes. Forgetting another memory commits the previous one first.
- "Forget all" asks for a second click and resets if focus leaves the button.
- Turning memory off keeps saved memories but stops saving and using them; the list dims and the header explains this. Turning it back on restores them.
- Editing saves on Enter or Save, cancels on Escape; an empty edit is discarded.
- The notice appears in the conversation at the moment a memory is saved, never silently.

## Content guidelines
- Write memories as short, third-person facts: "Prefers concise answers", not "The user has mentioned that they would like…".
- Say where each memory came from, so users can tell inferred facts from ones they asked for.
- Use "Forget" rather than "Delete" — it describes the effect on the assistant.

## Accessibility
- The master switch is a `role="switch"` with an accessible name.
- Edit and forget buttons name the memory they act on; they stay visible on touch and on keyboard focus, and only hide on hover-capable screens.
- Undo and the memory-updated notice are `role="status"` so screen readers announce them.
- The inline editor takes focus when opened; Escape cancels without saving.

## Related patterns
- **Incognito Toggle** — Use it as well as the up-front control that stops memories being saved for one chat.
- **Connector Panel** — Use it instead when the question is which external sources the AI can read, not what it has learned.
- **Tool Approval** — Use it instead when an action is risky enough to ask before it runs, rather than saving inline and reversing here.

Do’s and don’ts

Do use it for

  • Assistants that save facts about the user across conversations (role, preferences, projects) and use them in later answers.
  • Products where personalisation could surprise people — they should be able to see why an answer assumed something.
  • Anywhere data-protection rules give users the right to see and delete what is stored about them.

Don’t use it for

  • Context that only lives within one conversation — that is the chat history, not memory.
  • Organisation-level knowledge (docs, connectors). Use Connector Panel for sources the AI can read.
  • As the only control. Pair it with a temporary-chat option (Incognito Toggle) so people can avoid saving in the first place.