# Memory

> Where people see and edit what an AI assistant remembers, grouped by category with the chats it came from. Edits, undo and pause all move smoothly.

Canonical: https://hextaui.com/blocks/memory
Last updated: 2026-10-29

## Overview

Memory is the page where people see what an assistant remembers about them, fix what it got wrong and take things back. It opens with one summary: whether memory is on, paused or off, how many things the assistant remembers and a meter with one colour per category. Memories are grouped under those categories, each one sentence with the chat it came from, when it was saved and when it was last used. Long memories fold to three lines with Show more, and text in any direction lays out on its own.

Click a memory to edit it in place. The text stays exactly where it was and a field appears around it, with Enter to save and Esc to cancel. Clearing the text asks whether to delete it instead, so an empty memory is never saved. Delete folds the memory away and shows a toast with Undo for five seconds, and onDelete only runs once that window closes, so Undo needs nothing from your server. Select picks several memories for one delete behind a confirmation, and Forget everything asks for a typed word first.

Pause works like a temporary chat for everything. The summary says memory is paused and that nothing was deleted, the list stays visible but quieter, and Resume is the same button. Turning Reference saved memories off says what that means in the same place. Near the limit the meter says so calmly and offers Review least used, which lists the oldest unused memories first.

Every change moves the page smoothly. A memory, a whole category, the add form, a note or an error opens and closes with its height, so everything below slides instead of jumping, and Undo brings a memory back the same way. A memory saved in the background keeps its place when its real id arrives. With reduced motion everything changes at once.

Search covers the memory, its source and its labels. Filter narrows to a category or a project, since a memory can apply to every chat or to one project, and sorts by newest, recently used or least used. Sorting by use shows one list with each memory’s category. While a memory is being edited the list keeps its order, so it never jumps out from under the cursor. Memories that disagree, such as short answers and detailed answers, are flagged with May conflict with and a link to the other one. Long lists render 50 rows at a time and load more as you scroll.

MemoryUpdatedChip is the quiet line a chat shows when the assistant saves something, with the memory and Manage. Drop it into Chat Thread messages when your save_memory tool runs.

## Installation

### Step 1: Get HextaUI Pro

One payment unlocks every block, including future ones. [See pricing](https://hextaui.com/pricing).

### Step 2: Add the Pro registry to components.json

```json title="components.json"
{
  "registries": {
    "@hextaui-pro": {
      "url": "https://hextaui.com/r/pro/{name}.json",
      "headers": {
        "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
      }
    }
  }
}
```

### Step 3: Add your token

Create a token on your [account page](https://hextaui.com/account) and put it in `.env.local` as `HEXTAUI_PRO_TOKEN`.

### Step 4: Add the block

```bash
npx shadcn@latest add @hextaui-pro/memory
```

## Usage

### Save memories from an AI SDK tool

Give the model a save_memory tool that writes to your database, and render Memory with the same rows. Every change is shown at once and rolls back with a message if your callback throws. Deletes wait out the undo window before onDelete runs.

```tsx
import { stepCountIs, streamText, tool, type ModelMessage } from "ai"
import { z } from "zod"

import type { MemoryInput, MemoryRecord } from "@/components/blocks/memory/memory"

type MemoryStore = {
  list: (userId: string) => Promise<MemoryRecord[]>
  add: (userId: string, input: MemoryInput & { source: { label: string; href: string } }) => Promise<MemoryRecord>
  markUsed: (userId: string, ids: string[]) => Promise<void>
  settings: (userId: string) => Promise<{ enabled: boolean; paused: boolean }>
}

function memoryInstructions(memories: MemoryRecord[]) {
  if (memories.length === 0) return ""
  return [
    "What you remember about the user. Use it when it helps, and never mention it unprompted.",
    ...memories.map((memory) => `- [${memory.id}] ${memory.text}`),
  ].join("\n")
}

export async function respond({
  model,
  messages,
  store,
  userId,
  chat,
}: {
  model: Parameters<typeof streamText>[0]["model"]
  messages: ModelMessage[]
  store: MemoryStore
  userId: string
  chat: { id: string; title: string }
}) {
  const { enabled, paused } = await store.settings(userId)
  const active = enabled && !paused
  const memories = active ? await store.list(userId) : []
  if (memories.length > 0) await store.markUsed(userId, memories.map((memory) => memory.id))

  return streamText({
    model,
    messages,
    system: memoryInstructions(memories),
    stopWhen: stepCountIs(5),
    tools: active
      ? {
          save_memory: tool({
            description:
              "Save one durable fact about the user that will help in future chats, such as a preference, their role or an ongoing project. Write it as a short sentence about the user. Don't save secrets, health details or anything they asked you to forget.",
            inputSchema: z.object({
              text: z.string().max(500),
              category: z.enum(["about", "preferences", "work", "projects"]),
            }),
            execute: async ({ text, category }) => {
              const memory = await store.add(userId, {
                text,
                category,
                scope: null,
                source: { label: chat.title, href: `/chat/${chat.id}` },
              })
              return { id: memory.id, text: memory.text }
            },
          }),
        }
      : undefined,
  })
}
```

### Show Memory updated in a chat

When the save_memory tool part appears in a message, render MemoryUpdatedChip inside ChatAssistantMessage. It reads Saving to memory while the call runs and Memory updated when it’s done, and Manage opens Memory with that memory highlighted.

```tsx
"use client"

import * as React from "react"
import { useChat } from "@ai-sdk/react"
import { getToolName, isToolUIPart, type UIMessage } from "ai"

import { Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"

import { ChatAssistantMessage, ChatThread, ChatUserMessage } from "../chat-thread/chat-thread"
import { Markdown } from "../markdown/markdown"
import { PromptInput, PromptInputBody, PromptInputSubmit, PromptInputTextarea } from "../prompt-input/prompt-input"
import { Memory, MemoryUpdatedChip, type MemoryChanges, type MemoryRecord } from "@/components/blocks/memory/memory"

function textOf(message: UIMessage) {
  return message.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")
}

async function request<T>(input: string, init?: RequestInit) {
  const response = await fetch(input, { ...init, headers: { "content-type": "application/json" } })
  if (!response.ok) {
    const body = (await response.json().catch(() => ({}))) as { error?: string }
    throw new Error(body.error ?? "Something went wrong.")
  }
  return (response.status === 204 ? null : await response.json()) as T
}

export function ChatWithMemory() {
  const { messages, status, sendMessage } = useChat()
  const [open, setOpen] = React.useState(false)
  const [highlight, setHighlight] = React.useState<string | null>(null)
  const [memories, setMemories] = React.useState<MemoryRecord[]>([])
  const [settings, setSettings] = React.useState({ enabled: true, history: false, paused: false })

  const manage = async (id?: string) => {
    const data = await request<{ memories: MemoryRecord[]; settings: typeof settings }>("/api/memories")
    setMemories(data.memories)
    setSettings(data.settings)
    setHighlight(id ?? null)
    setOpen(true)
  }

  return (
    <>
      <ChatThread
        busy={status === "submitted" || status === "streaming"}
        composer={
          <PromptInput status={status} onSubmit={({ text }) => sendMessage({ text })}>
            <PromptInputBody>
              <PromptInputTextarea />
              <PromptInputSubmit />
            </PromptInputBody>
          </PromptInput>
        }
      >
        {messages.map((message) =>
          message.role === "user" ? (
            <ChatUserMessage key={message.id} id={message.id} text={textOf(message)} />
          ) : (
            <ChatAssistantMessage key={message.id} id={message.id} text={textOf(message)}>
              <Markdown text={textOf(message)} />
              {message.parts.filter(isToolUIPart).flatMap((part) => {
                if (getToolName(part) !== "save_memory") return []
                const input = part.input as { text?: string } | undefined
                const output = part.state === "output-available" ? (part.output as { id: string; text: string }) : null
                return [
                  <MemoryUpdatedChip
                    key={part.toolCallId}
                    memory={output?.text ?? input?.text ?? ""}
                    action="saved"
                    status={part.state === "output-error" ? "error" : output ? "done" : "saving"}
                    onManage={() => void manage(output?.id)}
                  />,
                ]
              })}
            </ChatAssistantMessage>
          )
        )}
      </ChatThread>
      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent size="lg">
          <DialogHeader>
            <DialogTitle>Memory</DialogTitle>
          </DialogHeader>
          <DialogBody>
            <div className="pb-6">
            <Memory
              memories={memories}
              enabled={settings.enabled}
              historyEnabled={settings.history}
              paused={settings.paused}
              highlight={highlight}
              onToggle={async (setting, value) => {
                await request("/api/memories/settings", { method: "PATCH", body: JSON.stringify({ [setting]: value }) })
                setSettings((current) => ({ ...current, [setting]: value }))
              }}
              onAdd={async (input) => {
                const memory = await request<MemoryRecord>("/api/memories", { method: "POST", body: JSON.stringify(input) })
                setMemories((current) => [memory, ...current])
                return memory
              }}
              onEdit={async (memory, changes: MemoryChanges) => {
                await request(`/api/memories/${memory.id}`, { method: "PATCH", body: JSON.stringify(changes) })
                setMemories((current) => current.map((entry) => (entry.id === memory.id ? { ...entry, ...changes } : entry)))
              }}
              onDelete={async (memory) => {
                await request(`/api/memories/${memory.id}`, { method: "DELETE" })
                setMemories((current) => current.filter((entry) => entry.id !== memory.id))
              }}
              onDeleteMany={async (targets) => {
                const ids = targets.map((entry) => entry.id)
                await request("/api/memories", { method: "DELETE", body: JSON.stringify({ ids }) })
                setMemories((current) => current.filter((entry) => !ids.includes(entry.id)))
              }}
              onForgetAll={async () => {
                await request("/api/memories?all=true", { method: "DELETE" })
                setMemories([])
              }}
            />
            </div>
          </DialogBody>
        </DialogContent>
      </Dialog>
    </>
  )
}
```

### A compact panel in settings

variant="compact" keeps one switch, the storage meter, search and the list, for a settings section or a side panel. Pass now to pin dates on the server and in tests.

```tsx
"use client"

import * as React from "react"
import { IconBookmark } from "@tabler/icons-react"

import { SettingsSection, SettingsShell, type SettingsSectionItem } from "../settings/settings"
import { Memory, MemorySkeleton, type MemoryRecord } from "@/components/blocks/memory/memory"

const sections: SettingsSectionItem[] = [
  {
    id: "personalization",
    label: "Personalization",
    description: "What Hexta remembers about you across chats.",
    icon: <IconBookmark />,
  },
]

type Settings = { enabled: boolean; history: boolean; paused: boolean }

export function PersonalizationSettings({
  load,
  save,
}: {
  load: () => Promise<{ memories: MemoryRecord[]; settings: Settings; used: number; limit: number }>
  save: {
    toggle: (settings: Partial<Settings>) => Promise<void>
    edit: (id: string, text: string) => Promise<void>
    remove: (id: string) => Promise<void>
  }
}) {
  const [data, setData] = React.useState<Awaited<ReturnType<typeof load>> | null>(null)
  const [failed, setFailed] = React.useState(false)

  const reload = React.useCallback(() => {
    setFailed(false)
    load()
      .then(setData)
      .catch(() => setFailed(true))
  }, [load])

  React.useEffect(reload, [reload])

  return (
    <SettingsShell sections={sections}>
      <SettingsSection
        id="personalization"
        status={failed ? "error" : data ? "ready" : "loading"}
        skeleton={<MemorySkeleton rows={3} />}
        onRetry={reload}
      >
        {data ? (
          <Memory
            variant="compact"
            memories={data.memories}
            enabled={data.settings.enabled}
            limit={data.limit}
            used={data.used}
            onToggle={(setting, value) => save.toggle({ [setting]: value })}
            onEdit={async (memory, changes) => {
              if (changes.text) await save.edit(memory.id, changes.text)
            }}
            onDelete={async (memory) => {
              await save.remove(memory.id)
              setData((current) =>
                current ? { ...current, memories: current.memories.filter((entry) => entry.id !== memory.id) } : current
              )
            }}
          />
        ) : null}
      </SettingsSection>
    </SettingsShell>
  )
}
```

## Anatomy

The parts you compose, from the outside in.

| Part | Description |
| --- | --- |
| `Memory` | The whole page: the summary, the switches, search and filters, the add form, the grouped list with selection, and the dialogs. |
| `Summary` | Whether memory is on, paused or off, how many things are remembered, the storage meter by category, and the paused, off and nearly full notes. |
| `Category group` | A heading with the category’s colour and count, above its own list of memories. Empty categories fold away. |
| `Memory row` | The sentence, then one line with the source chat and date, last used and the project, any conflict, and a menu with Edit, Category, Used in, Open source chat and Delete. |
| `MemoryUpdatedChip` | The line inside a chat message when a memory is saved, updated or removed, with Manage. |
| `MemorySkeleton` | A loading placeholder shaped like the page, for SettingsSection’s skeleton prop. |
| `Forget everything dialog` | Deletes every memory after the confirm word is typed. It can’t be undone, so it waits for onForgetAll. |

## API reference

### Memory

Also accepts every div prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `memories` | `MemoryRecord[]` | – | { id, text, category?, scope?, source?, createdAt, lastUsedAt?, conflictsWith? }. scope is a project value, or null for every chat. source is { label, href? } for the chat it came from. conflictsWith lists ids of memories that disagree with this one. Dates take a Date, an ISO string or a timestamp. |
| `enabled` | `boolean` | – | Whether the assistant saves and uses memories. When off, the summary explains it and the list stays as it is. |
| `historyEnabled` | `boolean` | `false` | Whether the assistant may look back at past chats. |
| `paused` | `boolean` | `false` | Pauses memory. The summary says so and that nothing was deleted, and Pause becomes Resume. |
| `onToggle` | `(setting: "enabled" \| "history" \| "paused", value: boolean) => void \| Promise<void>` | – | Called when a switch, Pause or Resume changes. The control moves right away and goes back if this throws. |
| `onAdd` | `(input: { text, category?, scope? }) => Promise<MemoryRecord>` | – | Adds Add memory. The new memory shows as Saving until you return the saved record. Throw to show Try again and Discard. |
| `onEdit` | `(memory, changes: { text?, category?, scope? }) => void \| Promise<void>` | – | Called for inline edits and for Category and Used in in the row menu. Without it memories can’t be edited. |
| `onDelete` | `(memory) => void \| Promise<void>` | – | Delete the memory. Runs after the undo window. Throw and it comes back with a message. |
| `onDeleteMany` | `(memories) => void \| Promise<void>` | – | Delete a selection in one call. Without it, onDelete runs for each one. |
| `onForgetAll` | `() => void \| Promise<void>` | – | Adds Forget everything. The dialog waits for it and keeps everything if it throws. |
| `limit` | `number` | `4000` | How much memory holds, in the same unit as used. At 80% the summary says it’s nearly full and at 100% Add memory turns off. |
| `used` | `number` | – | How much is used, if your server counts tokens. Defaults to the characters in every memory. |
| `categories` | `{ value, label, tone? }[]` | `About you, Preferences, Work, Projects` | The categories memories are grouped by, in this order. tone is chart-1 to chart-5 and colours the group’s dot and its part of the meter. |
| `projects` | `{ value, label }[]` | `[]` | Projects a memory can be scoped to. Adds Used in to the filter, the row menu and the add form. |
| `highlight` | `string \| null` | – | A memory id to scroll to, focus and briefly highlight, for example after Manage in a chat. Clears search and filters so it’s visible. |
| `variant` | `"full" \| "compact"` | `"full"` | compact keeps one switch, the meter, search and the list, without selection or Forget everything. |
| `assistant` | `string` | `"Hexta"` | The assistant’s name in every sentence. |
| `undoTimeout` | `number` | `5000` | How long Undo stays, in milliseconds. 0 deletes right away. |
| `pageSize` | `number` | `50` | Rows rendered at a time. More load as the end of the list scrolls into view. |
| `confirmWord` | `string` | `"forget"` | The word typed to confirm Forget everything. |
| `now` | `Date` | – | Pins the clock for dates, for tests and server rendering. |

### MemoryUpdatedChip

Also accepts every div prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `memory` | `{ text } \| string` | – | The memory, shown on one line with the full text on hover. |
| `action` | `"saved" \| "updated" \| "deleted"` | `"updated"` | Saved to memory, Memory updated or Removed from memory. |
| `status` | `"saving" \| "done" \| "error"` | `"done"` | saving shimmers while the tool runs. error shows the message and Try again. |
| `error` | `string` | – | Replaces Couldn’t save to memory. |
| `onManage` | `() => void` | – | Adds Manage, usually opening Memory with highlight set to this memory. |
| `onRetry` | `() => void` | – | Adds Try again when status is error. |

## Keyboard

| Key | Action |
| --- | --- |
| `Enter` | On a memory, starts editing it. While editing, saves. |
| `Esc` | Cancels the edit, clears the search or leaves Select. |
| `F2` | Edits the memory whose row has focus. |
| `Delete` | Deletes the memory whose row has focus, with Undo. Backspace works too. |
| `⇧` `Enter` | Adds a line break while editing or adding. |

## Accessibility

- Each category is its own list, named by its heading and aware of its full length, so screen readers say “3 of 12” even while only part of it is rendered.
- Saving, deleting, restoring, pausing and failures are announced through a polite live region, such as “Memory deleted. Undo is available for a few seconds.”
- Focus never gets lost: after a delete it moves to the next memory, Undo puts it back on the restored one, and Manage focuses the highlighted memory. A row on its way out is inert.
- Status is never color alone. On, paused and off, saving, errors, conflicts and the storage meter all have their own words, and the meter reports “72% full”.
- With reduced motion, rows, groups and notes appear and disappear at once instead of folding.
- Text uses dir="auto", so a memory in Arabic or Hebrew reads right to left inside an English page.

## Built with

The free HextaUI components Memory is made from. Each one installs on its own.

- [Alert dialog](https://hextaui.com/docs/alert-dialog) - A confirmation dialog for destructive or important actions that waits for async work, and becomes a bottom sheet on phones.
- [Badge](https://hextaui.com/docs/badge) - Status labels with colored dots, removable tags that slide closed and counts that roll to their new value.
- [Button](https://hextaui.com/docs/button) - Buttons in every variant and size, with a built-in loading, success and error flow that skips the spinner for fast requests.
- [Checkbox](https://hextaui.com/docs/checkbox) - A checkbox whose check draws in, with indeterminate parents, groups and labels that share its hover.
- [Collapsible](https://hextaui.com/docs/collapsible) - A panel that shows and hides with height motion you can reverse mid-way, without jumping the layout.
- [Dropdown menu](https://hextaui.com/docs/dropdown-menu) - A menu of actions and options behind a button, with groups, submenus, checkbox and radio items, and shortcuts.
- [Empty](https://hextaui.com/docs/empty) - A placeholder for screens with nothing to show yet, with an icon, a message and the next action.
- [Field](https://hextaui.com/docs/field) - Labels, descriptions and errors wired to their control, with validation states and layouts for forms.
- [Hover card](https://hextaui.com/docs/hover-card) - A preview card that opens when a link is hovered or focused, for content sighted users can glance at.
- [Input](https://hextaui.com/docs/input) - A text input with three sizes, invalid and read-only states, native validation styling and a 16px touch font so phones never zoom in.
- [Input group](https://hextaui.com/docs/input-group) - An input with icons, text, buttons or a keyboard hint attached, sharing one border and focus ring.
- [Kbd](https://hextaui.com/docs/kbd) - Key caps for shortcuts that show the right symbols on every platform, read them out properly and press down with the real keys.
- [Number flow](https://hextaui.com/docs/number-flow) - Animated numbers where only the changed digits spin, with any Intl format and locale.
- [Progress](https://hextaui.com/docs/progress) - A bar or ring that shows how far a task has come, eases between updates and slides while the total is unknown.
- [Select](https://hextaui.com/docs/select) - Pick one or more options from a list that opens on the current value, with typeahead, groups and form support.
- [Skeleton](https://hextaui.com/docs/skeleton) - Placeholders that wait 150ms before showing, take the exact size of the content they wrap, and fade it in without moving anything.
- [Spinner](https://hextaui.com/docs/spinner) - A loading indicator with Apple-style ticks or a breathing ring that can wait before showing and stay long enough not to flicker.
- [Switch](https://hextaui.com/docs/switch) - An on/off toggle you can tap, press or drag, with a thumb that stretches under your finger and an optional pending state for saves.
- [Textarea](https://hextaui.com/docs/textarea) - Multi-line text that grows smoothly with what you write between a minimum and maximum number of rows, with counters, validation and a submit shortcut.
- [Toast](https://hextaui.com/docs/toast) - Brief messages that stack neatly, expand on hover, swipe away, and turn a loading state into success or error in place.

## Code

10 files, added to components/blocks/memory.
