{"slug":"memory-panel","category":"permissions","job":"trust","added":"2026-11-07T22:47Z","title":"Memory Panel","description":"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.","url":"https://ai-patterns.app/patterns/permissions/memory-panel","markdown":"https://ai-patterns.app/patterns/permissions/memory-panel.md","json":"https://ai-patterns.app/api/patterns/permissions/memory-panel","seenIn":[{"team":"chatgpt","source":"https://openai.com/index/memory-and-new-controls-for-chatgpt/"},{"team":"Google Gemini","source":"https://support.google.com/gemini/answer/18617127?hl=en&co=GENIE.Platform%3DDesktop"}],"spec":"# Memory Panel\n\n## Summary\nA 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.\n\n## When to use\n- Assistants that save facts about the user across conversations (role, preferences, projects) and use them in later answers.\n- Products where personalisation could surprise people — they should be able to see why an answer assumed something.\n- Anywhere data-protection rules give users the right to see and delete what is stored about them.\n\n## When not to use\n- Context that only lives within one conversation — that is the chat history, not memory.\n- Organisation-level knowledge (docs, connectors). Use Connector Panel for sources the AI can read.\n- As the only control. Pair it with a temporary-chat option (Incognito Toggle) so people can avoid saving in the first place.\n\n## Anatomy\n- Header: title, a one-line explanation that changes when memory is off, and a master switch.\n- Memory rows: the memory in plain language, an optional source line (\"From a chat on 12 Oct\"), and edit and forget actions.\n- Inline editor: a textarea replacing the row, with Save and Cancel.\n- Empty state: what memory is for and how to add one.\n- Footer: a count, an Undo affordance after a deletion, and a two-step \"Forget all\".\n- Memory-updated notice: a pill in the chat (\"Memory updated · …\") with a Manage link to the panel.\n\n## Behavior\n- 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.\n- \"Forget all\" asks for a second click and resets if focus leaves the button.\n- 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.\n- Editing saves on Enter or Save, cancels on Escape; an empty edit is discarded.\n- The notice appears in the conversation at the moment a memory is saved, never silently.\n\n## Content guidelines\n- Write memories as short, third-person facts: \"Prefers concise answers\", not \"The user has mentioned that they would like…\".\n- Say where each memory came from, so users can tell inferred facts from ones they asked for.\n- Use \"Forget\" rather than \"Delete\" — it describes the effect on the assistant.\n\n## Accessibility\n- The master switch is a `role=\"switch\"` with an accessible name.\n- 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.\n- Undo and the memory-updated notice are `role=\"status\"` so screen readers announce them.\n- The inline editor takes focus when opened; Escape cancels without saving.\n\n## Related patterns\n- **Incognito Toggle** — Use it as well as the up-front control that stops memories being saved for one chat.\n- **Connector Panel** — Use it instead when the question is which external sources the AI can read, not what it has learned.\n- **Tool Approval** — Use it instead when an action is risky enough to ask before it runs, rather than saving inline and reversing here.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { Brain, Check, Pencil, Trash2, X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface Memory {\n  id: string;\n  text: string;\n  /** Where it was learned, already formatted (\"From a chat on 12 Oct\"). */\n  source?: string;\n}\n\nexport interface MemoryPanelProps {\n  memories: Memory[];\n  /** Whether the AI may save and use memories at all. */\n  enabled: boolean;\n  onEnabledChange: (enabled: boolean) => void;\n  onEdit?: (id: string, text: string) => void;\n  /** Called once the undo window has passed, so the deletion can be committed. */\n  onDelete?: (id: string) => void;\n  onClearAll?: () => void;\n  title?: string;\n  /** How long a deleted memory can be restored, in ms. */\n  undoMs?: number;\n  className?: string;\n}\n\nexport function MemoryPanel({\n  memories,\n  enabled,\n  onEnabledChange,\n  onEdit,\n  onDelete,\n  onClearAll,\n  title = \"Memory\",\n  undoMs = 5000,\n  className,\n}: MemoryPanelProps) {\n  const [editingId, setEditingId] = React.useState<string | null>(null);\n  const [draft, setDraft] = React.useState(\"\");\n  const [pending, setPending] = React.useState<Memory | null>(null);\n  const [confirmClear, setConfirmClear] = React.useState(false);\n  const timer = React.useRef<number | undefined>(undefined);\n  const onDeleteRef = React.useRef(onDelete);\n  const titleId = React.useId();\n\n  React.useEffect(() => {\n    onDeleteRef.current = onDelete;\n  }, [onDelete]);\n\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  const visible = memories.filter((m) => m.id !== pending?.id);\n\n  function commitPending() {\n    window.clearTimeout(timer.current);\n    if (pending) onDeleteRef.current?.(pending.id);\n  }\n\n  function remove(memory: Memory) {\n    // Deleting another memory commits the previous one first: one undo at a time.\n    commitPending();\n    setPending(memory);\n    timer.current = window.setTimeout(() => {\n      onDeleteRef.current?.(memory.id);\n      setPending(null);\n    }, undoMs);\n  }\n\n  function undo() {\n    window.clearTimeout(timer.current);\n    setPending(null);\n  }\n\n  function startEdit(memory: Memory) {\n    setEditingId(memory.id);\n    setDraft(memory.text);\n  }\n\n  function saveEdit() {\n    const text = draft.trim();\n    if (editingId && text) onEdit?.(editingId, text);\n    setEditingId(null);\n  }\n\n  return (\n    <section\n      aria-labelledby={titleId}\n      className={cn(\"flex w-full flex-col overflow-hidden rounded-2xl border bg-card\", className)}\n    >\n      <header className=\"flex items-start gap-3 border-b px-4 py-3\">\n        <div className=\"min-w-0 flex-1\">\n          <h3 id={titleId} className=\"text-sm font-semibold\">\n            {title}\n          </h3>\n          <p className=\"mt-0.5 text-xs text-pretty text-muted-foreground\">\n            {enabled\n              ? \"Details the assistant remembers about you to personalise answers.\"\n              : \"Memory is off. Nothing new is saved, and saved memories aren't used.\"}\n          </p>\n        </div>\n        <button\n          type=\"button\"\n          role=\"switch\"\n          aria-checked={enabled}\n          aria-label=\"Use memory\"\n          onClick={() => onEnabledChange(!enabled)}\n          className={cn(\n            \"relative mt-0.5 inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors\",\n            enabled ? \"bg-foreground\" : \"bg-muted-foreground/30\"\n          )}\n        >\n          <motion.span\n            layout\n            transition={{ type: \"spring\", stiffness: 500, damping: 32 }}\n            className={cn(\"size-4 rounded-full bg-background shadow-sm\", enabled ? \"ml-[18px]\" : \"ml-0.5\")}\n          />\n        </button>\n      </header>\n\n      <ul className={cn(\"flex flex-col transition-opacity\", !enabled && \"opacity-50\")} aria-label=\"Saved memories\">\n        <AnimatePresence initial={false}>\n          {visible.map((memory) => (\n            <motion.li\n              key={memory.id}\n              layout\n              initial={{ opacity: 0, height: 0 }}\n              animate={{ opacity: 1, height: \"auto\" }}\n              exit={{ opacity: 0, height: 0 }}\n              transition={{ duration: 0.2, ease: \"easeOut\" }}\n              className=\"group overflow-hidden border-b last:border-b-0\"\n            >\n              {editingId === memory.id ? (\n                <form\n                  className=\"flex flex-col gap-2 px-4 py-3\"\n                  onSubmit={(e) => {\n                    e.preventDefault();\n                    saveEdit();\n                  }}\n                >\n                  <textarea\n                    autoFocus\n                    value={draft}\n                    onChange={(e) => setDraft(e.target.value)}\n                    onKeyDown={(e) => {\n                      if (e.key === \"Escape\") setEditingId(null);\n                      if (e.key === \"Enter\" && !e.shiftKey) {\n                        e.preventDefault();\n                        saveEdit();\n                      }\n                    }}\n                    aria-label=\"Edit memory\"\n                    rows={2}\n                    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\"\n                  />\n                  <div className=\"flex justify-end gap-1.5\">\n                    <IconButton label=\"Cancel\" onClick={() => setEditingId(null)}>\n                      <X className=\"size-3.5\" aria-hidden />\n                    </IconButton>\n                    <IconButton label=\"Save\" type=\"submit\" primary>\n                      <Check className=\"size-3.5\" aria-hidden />\n                    </IconButton>\n                  </div>\n                </form>\n              ) : (\n                <div className=\"flex items-start gap-3 px-4 py-3\">\n                  <div className=\"min-w-0 flex-1\">\n                    <p className=\"text-sm text-pretty\">{memory.text}</p>\n                    {memory.source && <p className=\"mt-0.5 text-xs text-muted-foreground\">{memory.source}</p>}\n                  </div>\n                  <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\">\n                    {onEdit && (\n                      <IconButton label={`Edit memory: ${memory.text}`} onClick={() => startEdit(memory)}>\n                        <Pencil className=\"size-3.5\" aria-hidden />\n                      </IconButton>\n                    )}\n                    <IconButton label={`Forget: ${memory.text}`} onClick={() => remove(memory)}>\n                      <Trash2 className=\"size-3.5\" aria-hidden />\n                    </IconButton>\n                  </div>\n                </div>\n              )}\n            </motion.li>\n          ))}\n        </AnimatePresence>\n\n        {visible.length === 0 && (\n          <li className=\"flex flex-col items-center gap-1 px-4 py-8 text-center\">\n            <Brain className=\"size-5 text-muted-foreground\" aria-hidden />\n            <p className=\"text-sm font-medium\">Nothing remembered yet</p>\n            <p className=\"text-xs text-muted-foreground\">Ask the assistant to remember something, like your role or tone.</p>\n          </li>\n        )}\n      </ul>\n\n      <footer className=\"flex min-h-11 items-center justify-between gap-3 border-t bg-muted/40 px-4 py-2\">\n        <AnimatePresence mode=\"wait\" initial={false}>\n          {pending ? (\n            <motion.div\n              key=\"undo\"\n              role=\"status\"\n              initial={{ opacity: 0, y: 4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: -4 }}\n              transition={{ duration: 0.15 }}\n              className=\"flex min-w-0 flex-1 items-center gap-3\"\n            >\n              <span className=\"truncate text-xs text-muted-foreground\">Memory forgotten</span>\n              <button type=\"button\" onClick={undo} className=\"shrink-0 text-xs font-medium underline-offset-2 hover:underline\">\n                Undo\n              </button>\n            </motion.div>\n          ) : (\n            <motion.span\n              key=\"count\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              className=\"text-xs text-muted-foreground\"\n            >\n              {visible.length} {visible.length === 1 ? \"memory\" : \"memories\"}\n            </motion.span>\n          )}\n        </AnimatePresence>\n\n        {onClearAll && visible.length > 0 && (\n          <button\n            type=\"button\"\n            onClick={() => {\n              if (!confirmClear) return setConfirmClear(true);\n              setConfirmClear(false);\n              commitPending();\n              setPending(null);\n              onClearAll();\n            }}\n            onBlur={() => setConfirmClear(false)}\n            className={cn(\n              \"shrink-0 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors\",\n              confirmClear ? \"bg-destructive text-white\" : \"text-muted-foreground hover:bg-accent hover:text-foreground\"\n            )}\n          >\n            {confirmClear ? \"Click again to forget all\" : \"Forget all\"}\n          </button>\n        )}\n      </footer>\n    </section>\n  );\n}\n\n/** A one-line notice for the chat, shown when the assistant saves something new. */\nexport function MemoryUpdatedNotice({\n  text,\n  onManage,\n  className,\n}: {\n  text: string;\n  onManage?: () => void;\n  className?: string;\n}) {\n  return (\n    <motion.div\n      initial={{ opacity: 0, y: 4 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ duration: 0.2, ease: \"easeOut\" }}\n      role=\"status\"\n      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)}\n    >\n      <Brain className=\"size-3.5 shrink-0 text-muted-foreground\" aria-hidden />\n      <span className=\"min-w-0 truncate\">\n        <span className=\"text-muted-foreground\">Memory updated · </span>\n        {text}\n      </span>\n      {onManage && (\n        <button\n          type=\"button\"\n          onClick={onManage}\n          className=\"shrink-0 rounded-full px-2 py-0.5 font-medium text-foreground transition-colors hover:bg-accent\"\n        >\n          Manage\n        </button>\n      )}\n    </motion.div>\n  );\n}\n\nfunction IconButton({\n  label,\n  children,\n  onClick,\n  type = \"button\",\n  primary,\n}: {\n  label: string;\n  children: React.ReactNode;\n  onClick?: () => void;\n  type?: \"button\" | \"submit\";\n  primary?: boolean;\n}) {\n  return (\n    <button\n      type={type}\n      onClick={onClick}\n      aria-label={label}\n      title={label.split(\":\")[0]}\n      className={cn(\n        \"flex size-7 items-center justify-center rounded-md transition-colors\",\n        primary ? \"bg-foreground text-background hover:bg-foreground/90\" : \"text-muted-foreground hover:bg-accent hover:text-foreground\"\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}