AI

Memory List

The things an assistant remembers about someone, as a list they can audit — inline editing behind a one-editor lock, a two-step bin that fires exactly once, an add box that refuses duplicates, per-memory and master switches, the "used in this chat" highlight, and four data states.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  Brain,
  ExternalLink,
  MessageSquareText,
  Pencil,
  Plus,
  RefreshCcw,
  Sparkles,
  Trash2,
} from "lucide-react"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/memory-list.json

Prompt

Build a React + TypeScript + Tailwind "MemoryList" component with zod and
lucide-react, reusing an existing switch primitive plus the shadcn Button,
Badge and Input.

Contract
- A zod schema (`memoryListItemSchema` in a sibling contract file) is the single
  source of truth for ONE memory: { id, text, createdAt: string|number|Date,
  active: boolean, fromConversation?: { id, title, href? } }.
- `text` is the sentence that goes into the prompt, so it is edited in place —
  what the user reads on the row is exactly what the model receives. Plain text,
  not markdown.
- `active` is STORED state ("keep it, but don't feed it"), a third option next to
  delete and use. `usedIds` on the envelope is SESSION state: the ids this turn's
  retrieval actually pulled in. Keep them apart — baking "used" into the item
  forces the data layer to rewrite every row on every turn, and unknown ids in
  `usedIds` must be ignored so a stale set is harmless.
- Envelope status — "loading" | "empty" | "error" | "ready" — is the panel's own
  render state. `status="ready"` with an empty array renders the empty body, so
  the two can never disagree on screen.
- Props: items; status; usedIds?; paused? / defaultPaused? / onPausedChange?;
  onAdd?(text); onEdit?(id, text); onDelete?(id); onToggleActive?(id, active);
  onOpenConversation?(conversationId); maxLength? (400, clamped >= 20);
  confirmWindowMs? (4000, clamped >= 1000); label? ("Memory"); emptyState?;
  errorMessage?; onRetry?; formatDate?; className plus the rest of the element
  props, ref forwarded to the root.
- EVERY control is gated on its callback. No onToggleActive → no switches (a
  static dot instead). No onEdit → no pencil. No onDelete → no bin. No onAdd → no
  add box. A controlled `paused` with no `onPausedChange` → no master switch. The
  panel degrades to a read-only audit view instead of rendering affordances that
  cannot change anything.

Behavior
- ONE EDITOR AT A TIME, with a soft lock. Clicking a second pencil while the open
  draft is dirty does NOT open it: focus returns to the live editor and an inline
  "save or cancel this edit first" line appears for ~2.4s. Opening the second
  editor would orphan the first draft with nothing on screen to say where it
  went; a modal would be heavier than the edit itself.
- Inside the editor: Enter saves, Shift+Enter adds a line, Esc cancels. Both keys
  bail out while `event.nativeEvent.isComposing` is true, or an IME candidate
  popup commits half a word for every CJK typist. Blur never saves — blur fires
  on the way to the Cancel button.
- Save is refused (aria-disabled, not the native `disabled` attribute, which
  would blur the button mid-action) when the trimmed draft is empty, unchanged,
  or longer than maxLength. Over the limit the counter turns destructive and the
  hint says how many characters have to go. Nothing is ever silently truncated on
  paste: cutting a user's sentence for them is worse than refusing to save it.
- The character counter only appears in the last `COUNTER_WINDOW` (40)
  characters, in the editor and in the add box alike. A counter that is always on
  is noise on a 30-character fact; one that shows up near the end is a warning.
- Opening an editor on a row whose text changed elsewhere (another device, a
  refetch) REBASES the draft only while it is untouched. A dirty draft is never
  overwritten — a slightly stale baseline beats losing what the human typed.
- DELETE IS TWO-STEP AND ONE-SHOT. The bin arms for confirmWindowMs and then
  disarms itself (a destructive control left hot behind a scrolled-away row is
  the one that gets hit later); Esc on the row disarms it immediately; opening an
  editor disarms it too. The confirming click fires onDelete AT MOST ONCE per
  arming, because the lock is a ref read and written synchronously inside the
  handler — several clicks dispatched in one task all read the same stale state,
  so a state-only guard would let the extras through. Each arming ticks a
  generation, so the lock expires by itself and a genuine second delete still
  works.
- The panel never removes a row itself. onDelete is a request; the row leaves
  when the consumer's `items` no longer contains it, which makes an optimistic
  list and a server-confirmed list both correct.
- FOCUS IS HANDED OVER, never dropped. Confirming a delete destroys the button
  that was clicked, so the neighbour row's bin (or the add box, if the list is
  now empty) takes focus once the row really leaves `items` — the handoff waits
  for the removal, so an async delete doesn't move focus off a live row. Closing
  an editor returns focus to the pencil that opened it.
- THE ADD BOX REFUSES DUPLICATES BY POINTING. Before calling onAdd, the trimmed
  text is compared against every existing memory on a normalized key (trimmed,
  whitespace-collapsed, case-folded). A match scrolls that row into view
  (behaviour "smooth" only when prefers-reduced-motion is not set), highlights it
  for ~4s and says so under the input — because what the user usually wants next
  is to switch that row back on or reword it, not to store the sentence twice.
  Enter submits, the input keeps focus after a successful add (adding comes in
  bursts), and the notice clears as soon as the text changes.
- PAUSE KEEPS EVERYTHING. The master switch is a "don't use these in new chats"
  flag, not a bulk delete: per-row states are untouched, a band under the header
  spells that out, and while paused no row claims to be in use — the "used in
  this chat" accent and the header count both stand down, because a highlight
  that keeps claiming credit while memory is off is a lie. The switch is
  controlled (`paused` + `onPausedChange`) or uncontrolled (`defaultPaused`).
- A row is highlighted only when its id is in `usedIds` AND it is active AND
  memory is not paused. The accent is a claim about the answer on screen right
  now.
- Dates are absolute and formatted in UTC by default. `Intl` in the runtime's own
  zone renders differently on the server and in the browser and produces a
  hydration mismatch on every row; a relative "3 days ago" would need a clock,
  and this component owns none. An unparseable instant prints NO date rather than
  the string "Invalid Date"; pass `formatDate` (returning null to print nothing)
  for your own wording or the visitor's zone.
- Provenance has three renderings and no fake ones: `href` → a real <a>;
  no href but an `onOpenConversation` handler → a button carrying the
  conversation id; neither → plain text, because a link that goes nowhere is
  worse than a label.
- Every timer (the arming window, the guard hint, the duplicate hint) is held in
  a ref, cleared before it is restarted, and cleared again on unmount. Identity
  changes are handled during render, not in an effect: a memory that leaves
  `items` immediately releases the editor, the armed bin, the guard and the
  duplicate highlight, so there is never a committed frame where a removed row
  still owns them.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  border-l-primary + bg-primary/5 for the "used in this chat" accent, bg-primary/10
  for the duplicate flash, bg-muted/50 for the paused band, text-destructive for
  the over-limit counter and the load failure, and the Button destructive variant
  for the armed bin. No hard-coded colours anywhere.
- State never rests on colour alone: "Used in this chat" / "Off" / "Paused" are
  worded badges, a switched-off memory is struck through, and the header line
  counts them ("7 memories · 3 used in this chat · 2 off").
- The root is a role="group" labelled by its heading — not a landmark, which
  would just add noise inside a settings page — holding ONE persistent sr-only
  role="status" line that announces adds, edits, deletes and toggles from an
  element the screen reader is already watching.
- Each row switch takes its accessible NAME from the memory text
  (aria-labelledby) and its state from aria-checked, so a screen reader hears
  which fact is about to stop being used. While that paragraph is replaced by the
  editor, the name moves to an inline aria-label instead of pointing at an id
  that left the document.
- Long facts use `wrap-anywhere` plus `min-w-0`, not `break-words`: only
  overflow-wrap: anywhere lowers the min-content width, which is what stops a
  pasted 90-character URL from widening the whole panel. Nothing is clamped and
  no row lives in a fixed-height box.
- The loading skeleton mirrors the header + row rhythm so the panel doesn't jump
  when data lands; every pulse and transition is motion-reduce-guarded, and the
  duplicate scroll falls back to an instant jump under reduced motion.
- cn() merges the consumer's className into the root, which also spreads the
  remaining element props and forwards its ref.

Customization levers
- Surface area: pass only the callbacks you actually support. onToggleActive
  alone gives a curatable but unerasable list; onDelete + onEdit without onAdd
  suits a memory store only the model writes to.
- Budgets: `maxLength` sizes one memory (raise it for verbose facts, lower it to
  force short ones) and `confirmWindowMs` decides how long the bin stays armed —
  shorten it on a dense list, lengthen it on a touch surface.
- Wording: `label` names the panel and its list, `emptyState` replaces the whole
  first-run body (a good place for your own "how memory works" copy),
  `errorMessage` + `onRetry` own the load failure, `formatDate` swaps the date
  format or drops dates entirely by returning null.
- Density: rows are `p-3` with `gap-3`; drop to `p-2`/`text-xs` for a sidebar, or
  raise the switch to `size="md"` for a touch target. The panel never scrolls
  internally — put it in your own scroll container if you have hundreds of
  memories, so nothing is hidden behind a silent cap.
- Highlight policy: `usedIds` is yours to fill. Feed it the retrieval step's
  output for "used in this chat", or the ids matched by a search box to reuse the
  same accent as a find-in-list affordance.

Concepts

  • A memory is an editable sentence, not a row of data — the string on the row is verbatim what the model gets, so the only honest way to correct it is to edit it in place. That is why the pencil opens a real editor over the text rather than a settings dialog somewhere else, and why the panel refuses to truncate a paste on the user's behalf.
  • One editor at a time, held by a soft lock — a second editor would orphan the first draft silently. Instead the panel keeps the live one, sends focus back to it and says why, for a couple of seconds. The lock only engages when the draft is actually dirty; an untouched editor yields immediately.
  • Armed, then one-shot — deleting takes two clicks, the arming disarms itself after a few seconds, and the confirming click is guarded by a ref read synchronously inside the handler, so several clicks in one task still fire exactly one onDelete. The generation counter ticks on every arming, so the lock expires instead of leaving a dead button behind.
  • Off, paused and deleted are three different things — a switched-off memory is kept and not fed; pausing switches the whole store off without touching a single row; deleting is the only irreversible one. Collapsing them into one control is what makes people afraid to curate at all.
  • "Used in this chat" is session state, not stored state — the highlight comes from the ids this turn's retrieval returned, kept on the envelope so the store never has to be rewritten per turn. It stands down while memory is paused or the row is off, because an accent that keeps claiming credit for an answer it did not shape is just decoration.
  • Duplicates are answered by pointing — the add box normalizes the text, finds the row that already says it, scrolls to it and flashes it instead of storing a second copy or dead-ending in an error. The next thing the user wants is almost always that row.

On This Page