Skip to content
Star on GitHub
WorkspaceMemory panel
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Memory panel

What the agent remembers about your team, editable and removable. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add memory-panel
Team memory7 memories
Used in 14 answers this week
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add memory-panel

React

$npx designme add memory-panel --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

A list of the things an agent remembers about a team, such as brand rules and working habits. Each memory shows where it came from, with that app's own mark (Slack, Figma, GitHub, Notion, Linear, Drive) or an icon for something said in chat, and what kind of memory it is, in the kind's colour. People can filter memories by kind, fix one in place, or tell the agent to forget it. A memory the agent learns arrives shimmering, then sits highlighted with a New tag, and the memories an answer uses pulse once and keep a Used tag, so the agent's long-term memory is visible, alive and correctable.

  • When your agent keeps memories across sessions and people should be able to see them, and where each one came from.
  • When someone needs to correct a rule the agent learned wrong.
  • When you want to show the agent learning something new while the panel is open.
  • When people should see which memories shaped an answer.
  • As a settings page or a flat block inside a chat thread.
03

Usage

Import it and drive it from your own state.

Import

import { MemoryPanel } from '@/components/designme/memory-panel/MemoryPanel';

Example

The demo above as a React component, 53 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { MemoryPanel, type MemoryActivity, type MemoryItem, type MemoryKind } from './MemoryPanel';
import type { BrandName } from '../lib/brands';
import type { IconName } from '../lib/icons';
import { useStory } from '../lib/useScript';

const KINDS: MemoryKind[] = [
  { value: 'brand', label: 'Brand', color: 'var(--dm-pl)' },
  { value: 'process', label: 'Process', color: 'var(--dm-pt)' }
];
// where each memory came from: an app's mark, or an icon for something said in chat
const START: MemoryItem[] = ([
  ['brand', 'Primary buttons use the accent blue, never black', 'figma', 'Pricing review in Figma, Sep 12'],
  ['brand', 'Headlines stay under eight words', '', 'You told the agent, Aug 30'],
  ['process', 'Ask Maya before publishing to the shared library', 'slack', 'A thread in #design-system, Sep 4'],
  ['brand', 'Card radius is 16px on web, 12px in the app', 'github', 'tokens.css on GitHub, Sep 18'],
  ['process', 'Run the launch checklist before every PR', 'notion', 'Launch checklist in Notion, Sep 20'],
  ['process', 'File design bugs in the Design QA project', 'linear', 'DES-214 in Linear, Sep 26'],
  ['brand', 'No em dashes in product copy', 'drive', 'Voice and tone guide in Drive, Oct 2']
] as const).map(([kind, text, brand, source], id) => ({ id, kind, text, source, ...(brand ? { brand: brand as BrandName } : { icon: 'comment' as IconName }) }));
const IDLE: MemoryActivity = { text: 'Used in 14 answers this week' };

/* ---------- Memory panel: the agent learns a rule from your edit in Figma, then drafts an email in Gmail with three memories, which pulse as it uses them ---------- */
export default function MemoryPanelDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const [list, setList] = useState(START);
  const [activity, setActivity] = useState(IDLE);
  const set = (id: number, patch: Partial<MemoryItem>) => setList(l => l.map(m => (m.id === id ? { ...m, ...patch } : m)));
  // the story stops for good at your first click, key or focus inside the panel; whatever it was halfway through settles
  useStory(ref, async ({ wait, taken }) => {
    await wait(2200);
    setList(l => [{ id: 100, kind: 'brand', text: 'Learning from your edit in Pricing v4', source: 'Just now', brand: 'figma', learning: true }, ...l]);
    const learnt = { learning: false, text: 'Use “Start free” instead of “Sign up” on CTAs', source: 'Your edit in Pricing v4, just now' };
    taken(() => { setList(l => l.map(m => (m.id === 100 && m.learning ? { ...m, ...learnt } : m))); setActivity(IDLE); });
    await wait(1500);
    set(100, { ...learnt, isNew: true });
    await wait(2600);
    set(100, { isNew: false, source: 'Your edit in Figma, today' });
    await wait(1000);
    setActivity({ text: 'Drafting the launch email in Gmail', live: true, brand: 'gmail' });
    await wait(1100);
    for (const id of [100, 1, 6]) { set(id, { used: true }); await wait(700); }
    setActivity({ text: 'Used 3 memories in the launch email draft', brand: 'gmail' });
  });
  return (
    <div ref={ref} style={{ width: '100%' }}>
      <MemoryPanel kinds={KINDS} memories={list} onMemoriesChange={setList} activity={activity} />
    </div>
  );
}
04

Props

Everything MemoryPanel takes.

PropTypeWhat it does
titleReactNodeLike "Team memory"
iconIconName
kindsrequiredMemoryKind[]
memoriesMemoryItem[]Every memory, newest first. Leave it out to let Edit and Forget keep their own list
defaultMemoriesMemoryItem[]
onMemoriesChange(memories: MemoryItem[]) => void
filterstringThe tab that is showing: 'all' or a kind's value
defaultFilterstring
onFilterChange(filter: string) => void
allLabelstringThe first tab
countLabel(count: number) => stringThe count under the title, like "7 memories"
showSourcesbooleanShows the marks of the apps the memories came from beside the count. On by default
activityMemoryActivityThe line at the foot, like what the agent is answering with its memory now
editedSourcestringThe source an edited memory gets
newLabelstringThe tag on a memory marked `isNew`
usedLabelstringThe tag on a memory marked `used`
onEdit(memory: MemoryItem) => voidCalled with the memory after an edit
onForget(memory: MemoryItem) => voidCalled when a memory is forgotten. It can still come back with Undo for `undoFor` ms
onRestore(memory: MemoryItem) => voidCalled when Undo brings a forgotten memory back
undoFornumberHow long Undo stays, in ms
barebooleanSits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own
classNamestring
05

Code

The full source, as the CLI installs it.

MemoryPanel.tsxReact · 241 lines
'use client';
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect, useReducedMotion } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { Brand, type BrandName } from '../lib/brands';
import { Seg, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './memory-panel.css';

/** A group of memories, with its own filter tab and colour */
export interface MemoryKind {
  value: string;
  /** Its tab, and the word under each of its memories, like "Brand" */
  label: string;
  /** Its colour, for that word, like 'var(--dm-pl)'. Pick one that reads as text, 4.5:1 on your surface */
  color: string;
}

/** Something the agent remembers */
export interface MemoryItem {
  id: string | number;
  /** Which kind it is, by value */
  kind: string;
  /** What it remembers, like "Headlines stay under eight words" */
  text: string;
  /** Where it came from, like "A thread in #design-system, Sep 4" */
  source?: string;
  /** The app it came from, shown as its own mark, like 'slack' or 'figma' */
  brand?: BrandName;
  /** An icon for where it came from when that isn't an app, like 'comment' for something said in chat. Used without a `brand` */
  icon?: IconName;
  /** Highlights it as just learned, on the accent's tint with a New tag */
  isNew?: boolean;
  /** Still being learned: the text shimmers, a spinner stands in for its source, and it can't be edited yet */
  learning?: boolean;
  /** Used in the answer the agent is writing: it pulses once when this turns on, and keeps a Used tag */
  used?: boolean;
}

/** The line at the foot of the panel */
export interface MemoryActivity {
  /** Like "Drafting the launch email in Gmail" or "Used in 14 answers this week" */
  text: string;
  /** Shimmers the text, while the agent is at work */
  live?: boolean;
  brand?: BrandName;
  icon?: IconName;
}

export interface MemoryPanelProps {
  /** Like "Team memory" */
  title?: ReactNode;
  icon?: IconName;
  kinds: MemoryKind[];
  /** Every memory, newest first. Leave it out to let Edit and Forget keep their own list */
  memories?: MemoryItem[];
  defaultMemories?: MemoryItem[];
  onMemoriesChange?: (memories: MemoryItem[]) => void;
  /** The tab that is showing: 'all' or a kind's value */
  filter?: string;
  defaultFilter?: string;
  onFilterChange?: (filter: string) => void;
  /** The first tab */
  allLabel?: string;
  /** The count under the title, like "7 memories" */
  countLabel?: (count: number) => string;
  /** Shows the marks of the apps the memories came from beside the count. On by default */
  showSources?: boolean;
  /** The line at the foot, like what the agent is answering with its memory now */
  activity?: MemoryActivity;
  /** The source an edited memory gets */
  editedSource?: string;
  /** The tag on a memory marked `isNew` */
  newLabel?: string;
  /** The tag on a memory marked `used` */
  usedLabel?: string;
  /** Called with the memory after an edit */
  onEdit?: (memory: MemoryItem) => void;
  /** Called when a memory is forgotten. It can still come back with Undo for `undoFor` ms */
  onForget?: (memory: MemoryItem) => void;
  /** Called when Undo brings a forgotten memory back */
  onRestore?: (memory: MemoryItem) => void;
  /** How long Undo stays, in ms */
  undoFor?: number;
  /** Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own */
  bare?: boolean;
  className?: string;
}

interface Gone { m: MemoryItem; at: number; leaving: boolean }

const plural = (n: number) => `${n} ${n === 1 ? 'memory' : 'memories'}`;
const Mark = ({ brand, icon }: { brand?: BrandName; icon?: IconName }) => brand ? <Brand name={brand} /> : icon ? <Icon name={icon} size="xs" /> : null;

/** What the agent remembers about the team, filtered by kind, each memory with the app or place it came from, editable in
    place or forgotten with an undo. New memories arrive highlighted, and the ones an answer uses pulse. */
export function MemoryPanel({
  title = 'Team memory', icon = 'book', kinds, memories, defaultMemories = [], onMemoriesChange, filter, defaultFilter = 'all', onFilterChange,
  allLabel = 'All', countLabel = plural, showSources = true, activity, editedSource = 'Edited by you, just now', newLabel = 'New', usedLabel = 'Used',
  onEdit, onForget, onRestore, undoFor = 4000, bare = true, className
}: MemoryPanelProps) {
  const [list, setList] = useControllable(memories, defaultMemories, onMemoriesChange);
  const [f, setF] = useControllable(filter, defaultFilter, onFilterChange);
  const [gone, setGone] = useState<Gone[]>([]);
  const [editing, setEditing] = useState<MemoryItem['id'] | null>(null);
  const timers = useRef(new Map<MemoryItem['id'], ReturnType<typeof setTimeout>>());
  const edit = useRef<HTMLSpanElement>(null);
  const kindOf = (kind: string) => kinds.find(k => k.value === kind);

  // a new tab repaints the list: the rows stagger in again and any Undo goes away
  const [paint, setPaint] = useState(0);
  const order = useRef(new Map<MemoryItem['id'], number>());
  const first = useRef(true);
  useIsoLayoutEffect(() => {
    if (first.current) { first.current = false; return; }
    timers.current.forEach(clearTimeout); timers.current.clear();
    order.current.clear(); setGone([]); setPaint(p => p + 1);
  }, [f]);
  useEffect(() => { const t = timers.current; return () => t.forEach(clearTimeout); }, []);

  // editing starts with the caret at the end of the text
  useIsoLayoutEffect(() => {
    const t = edit.current; if (editing === null || !t) return;
    t.focus();
    const s = document.getSelection(); s?.selectAllChildren(t); s?.collapseToEnd();
  }, [editing]);

  // a memory that has just been learned crossfades from its shimmering line to what it remembers, and its source comes in
  const learnt = useRef(new Set<MemoryItem['id']>());
  list.forEach(m => { if (m.learning) learnt.current.add(m.id); });

  const commit = (m: MemoryItem, text: string) => {
    const next: MemoryItem = { ...m, text: text.trim() || m.text, source: editedSource, brand: undefined, icon: 'edit' };
    setEditing(null);
    setList(list.map(x => (x.id === m.id ? next : x)));
    onEdit?.(next);
  };
  const forget = (m: MemoryItem) => {
    const at = list.indexOf(m);
    setList(list.filter(x => x !== m)); onForget?.(m);
    order.current.delete(m.id);
    setGone(g => [...g, { m, at, leaving: false }]);
    timers.current.set(m.id, setTimeout(() => {
      setGone(g => g.map(x => (x.m === m ? { ...x, leaving: true } : x)));
      timers.current.set(m.id, setTimeout(() => { setGone(g => g.filter(x => x.m !== m)); timers.current.delete(m.id); }, 320));
    }, undoFor));
  };
  const restore = (g: Gone) => {
    clearTimeout(timers.current.get(g.m.id)); timers.current.delete(g.m.id);
    setGone(x => x.filter(y => y !== g));
    const next = [...list]; next.splice(Math.min(g.at, next.length), 0, g.m);
    setList(next); onRestore?.(g.m);
  };

  // forgotten memories hold their place with an Undo until it times out
  const rows: ({ m: MemoryItem; g?: undefined } | { g: Gone; m?: undefined })[] = list.map(m => ({ m }));
  for (const g of gone) rows.splice(Math.min(g.at, rows.length), 0, { g });
  const shown = rows.filter(r => f === 'all' || (r.m ?? r.g.m).kind === f);
  // rows that were there when the tab painted stagger in; anything that arrives later comes in on its own
  if (!order.current.size) shown.forEach((r, i) => { if (r.m) order.current.set(r.m.id, i); });
  const delay = (id: MemoryItem['id']) => (order.current.get(id) ?? 0) * 40 + 'ms';
  // a memory the agent learns while the panel is open opens its own height, so the panel grows instead of jumping
  const reduce = useReducedMotion(), grown = useRef(new WeakSet<HTMLElement>());
  const grow = (el: HTMLDivElement | null) => {
    if (!el || grown.current.has(el)) return; grown.current.add(el);
    if (reduce || !el.animate) return;
    const cs = getComputedStyle(el), ease = cs.getPropertyValue('--dm-ease').trim() || 'ease';
    el.animate([{ height: '0px', paddingTop: '0px', paddingBottom: '0px', opacity: 0, filter: 'blur(4px)', overflow: 'hidden' },
      { height: cs.height, paddingTop: cs.paddingTop, paddingBottom: cs.paddingBottom, opacity: 1, filter: 'blur(0px)', overflow: 'hidden' }], { duration: 560, easing: ease });
  };
  const apps = [...new Set(list.map(m => m.brand).filter((b): b is BrandName => !!b))];

  return (
    <div data-dm="" className={cx('dm-mmx', !bare && 'dm-panel', className)} style={bare ? { paddingInline: 0 } : undefined}>
      <div className="dm-mmx-h">
        <span className="dm-mmx-ic"><Icon name={icon} /></span>
        <div className="dm-mmx-ht">
          <b>{title}</b>
          <span className="dm-mmx-sub">
            <Swap className="dm-muted dm-mmx-st">{countLabel(list.length)}</Swap>
            {showSources && apps.length > 0 && <span className="dm-mmx-srcs" aria-hidden="true">{apps.map(b => <span key={b}><Brand name={b} /></span>)}</span>}
          </span>
        </div>
        <div className="dm-mmx-tab" style={{ display: 'flex' }}>
          <Seg label="Filter" value={f} onChange={setF} options={[{ value: 'all', label: allLabel }, ...kinds.map(k => ({ value: k.value, label: k.label }))]} />
        </div>
      </div>
      <div className="dm-mmx-list">
        {shown.map(r => {
          if (r.g) {
            const { m, leaving } = r.g;
            return (
              <div key={`${paint}-gone-${m.id}`} className={cx('dm-mmx-gone', leaving ? 'dm-leave' : 'dm-enter')}>
                <span>Forgot “{m.text.slice(0, 32)}{m.text.length > 32 ? '…' : ''}”</span>
                <button type="button" onClick={() => restore(r.g)}>Undo</button>
              </div>
            );
          }
          const m = r.m, arriving = !!(m.isNew || m.learning) && !order.current.has(m.id), k = kindOf(m.kind);
          const fresh = learnt.current.has(m.id) && !m.learning;
          return (
            // a memory that turns `used` pulses once, as the class arrives
            <div key={`${paint}-${m.id}`} ref={arriving ? grow : undefined}
              className={cx('dm-mmx-m', !arriving && 'dm-enter', m.isNew && 'dm-new', m.learning && 'dm-learn', m.used && 'dm-use')}
              style={{ '--dm-k': k?.color ?? 'var(--dm-muted)', ...(arriving ? null : { animationDelay: delay(m.id) }) } as CSSProperties}>
              <span key={m.learning ? 'learn' : 'from'} className={cx('dm-mmx-src', fresh && 'dm-enter')} aria-hidden="true">
                {m.learning ? <span className="dm-spin" /> : <Mark brand={m.brand} icon={m.icon} />}
              </span>
              <span className="dm-mmx-b">
                {editing === m.id
                  ? <span key="edit" ref={edit} className="dm-t" spellCheck={false} contentEditable suppressContentEditableWarning
                      onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
                      onBlur={e => commit(m, e.currentTarget.textContent ?? '')}>{m.text}</span>
                  : <Swap key="view" className={cx('dm-t', m.learning && 'dm-shim')}>{m.text}</Swap>}
                <span className="dm-mmx-meta">
                  {k && <span className="dm-k">{k.label}</span>}
                  {k && m.source && <span aria-hidden="true">·</span>}
                  {m.source && <span className="dm-s">{m.source}</span>}
                  {m.isNew && <span className="dm-tag dm-nw">{newLabel}</span>}
                  {m.used && <span className="dm-tag dm-nw">{usedLabel}</span>}
                </span>
              </span>
              <span className="dm-acts">
                <Tip label="Edit"><button type="button" className="dm-ib" aria-label="Edit" disabled={m.learning} onClick={() => setEditing(m.id)}><Icon name="edit" size="xs" /></button></Tip>
                <Tip label="Forget"><button type="button" className="dm-ib" aria-label="Forget" disabled={m.learning} onClick={() => forget(m)}><Icon name="trash" size="xs" /></button></Tip>
              </span>
            </div>
          );
        })}
      </div>
      {activity && (
        <div className="dm-mmx-f" aria-live="polite">
          <span className="dm-mmx-fi" aria-hidden="true"><Mark brand={activity.brand} icon={activity.icon ?? (activity.brand ? undefined : 'spark')} /></span>
          <Swap className={cx('dm-mmx-ft', activity.live && 'dm-shim')}>{activity.text}</Swap>
        </div>
      )}
    </div>
  );
}
memory-panel.cssReact · 62 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Memory panel ---------- */
/* each memory shows where it came from, as that source's own mark (Slack, Figma, GitHub, Notion, Linear, Drive) or an icon
   (something said in chat, an edit), and its kind in the kind's colour (--k). A memory the agent learns arrives shimmering
   while it works the rule out, then sits on the accent's tint with a New tag; the memories an answer uses pulse once and say so */
.dm-mmx{padding:var(--dm-s-3);display:flex;flex-direction:column;gap:var(--dm-s-2);min-height:330px}
/* on a card the panel sits centred in the room kept for its tallest moment, and glides up as a new memory opens its row */
.dm-stage-in:has(>.dm-mmx){display:flex;flex-direction:column;justify-content:center}
.dm-mmx-h{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.dm-mmx-ic{width:32px;height:32px;border-radius:10px;background:var(--dm-pp-bg);color:var(--dm-pp);display:grid;place-items:center;flex:none}
.dm-mmx-ht{display:flex;flex-direction:column;flex:1;min-width:0;font-size:13.5px}
.dm-mmx-ht b{font-weight:500}
.dm-mmx-sub{display:flex;align-items:center;gap:var(--dm-s-2);min-width:0}
.dm-mmx-st{font-size:12px;white-space:nowrap}
/* the apps it has learned from, as a row of their marks */
.dm-mmx-srcs{display:inline-flex;align-items:center;gap:3px}
.dm-mmx-srcs span{width:18px;height:18px;border-radius:5px;display:grid;place-items:center;flex:none;background:var(--dm-surface);color:var(--dm-fg);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-rail) 50%,transparent)}
.dm-mmx-srcs .dm-brand{width:11px;height:11px}
@media (max-width:600px){.dm-mmx-srcs{display:none}}
.dm-mmx-tab button{font-size:12px;height:26px;padding:0 9px}
.dm-mmx-list{display:flex;flex-direction:column}
.dm-mmx-m{display:flex;align-items:flex-start;gap:10px;padding:var(--dm-s-2) 6px;border-radius:10px;font-size:13px;line-height:1.5;transition:background .3s}
@media (hover:hover) and (pointer:fine){.dm-mmx-m:hover{background:var(--dm-row)}}
.dm-mmx-src{width:28px;height:28px;margin-top:1px;border-radius:8px;display:grid;place-items:center;flex:none;background:var(--dm-surface);color:var(--dm-fg2);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-rail) 50%,transparent)}
.dm-mmx-src .dm-brand{width:15px;height:15px;color:var(--dm-fg)}
.dm-mmx-src .dm-spin{width:12px;height:12px}
.dm-mmx-b{flex:1;min-width:0;display:flex;flex-direction:column}
.dm-mmx-m .dm-t{outline:0;border-radius:5px;padding:0 3px;margin:0 -3px}
.dm-mmx-m .dm-t[contenteditable="true"]{background:var(--dm-surface);box-shadow:0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent)}
.dm-mmx-m .dm-t[contenteditable="true"]:focus{box-shadow:0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
/* under it: its kind in the kind's colour, then where it came from */
.dm-mmx-meta{display:flex;align-items:center;gap:6px;min-width:0;font-size:11.5px;color:var(--dm-muted);white-space:nowrap}
.dm-mmx-meta>*{flex:none}
.dm-mmx-meta .dm-k{color:var(--dm-k);font-weight:500}
.dm-mmx-meta .dm-s{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
.dm-mmx-meta .dm-tag{height:17px;padding:0 5px;font-size:11px}
.dm-mmx-meta .dm-tag.dm-nw{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-fg))}
.dm-mmx-m .dm-acts{display:flex;opacity:0;transition:opacity .2s}
.dm-mmx-m:focus-within .dm-acts{opacity:1}
@media (hover:hover) and (pointer:fine){.dm-mmx-m:hover .dm-acts{opacity:1}}
.dm-mmx-m .dm-ib{width:24px;height:24px}
.dm-mmx-m.dm-learn .dm-acts{visibility:hidden}
.dm-mmx-m.dm-new{background:var(--dm-sel)}
/* a memory an answer uses pulses once with the accent's tint, and its source lifts */
.dm-mmx-m.dm-use{animation:dm-mmxuse 1.4s var(--dm-ease)}
.dm-mmx-m.dm-use .dm-mmx-src{animation:dm-mmxlift .6s var(--dm-ease)}
@keyframes dm-mmxuse{0%{background:color-mix(in srgb,var(--dm-accent) 16%,transparent)}100%{background:transparent}}
@keyframes dm-mmxlift{40%{transform:translateY(-2px) scale(1.08)}}
.dm-mmx-gone{display:flex;align-items:center;justify-content:space-between;gap:var(--dm-s-2);padding:6px var(--dm-s-2);border-radius:10px;font-size:12.5px;color:var(--dm-muted);background:var(--dm-chip)}
.dm-mmx-gone button{color:var(--dm-accent-fg,var(--dm-accent));font-weight:500;font-size:12.5px}
/* the line at the foot: how often the memory is used, or the answer it is helping with now */
.dm-mmx-f{display:flex;align-items:center;gap:var(--dm-s-2);margin-top:auto;padding:10px 6px 2px;border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:12px;color:var(--dm-muted)}
.dm-mmx-fi{display:grid;place-items:center;flex:none;color:var(--dm-fg2)}
.dm-mmx-fi .dm-brand{width:14px;height:14px}
.dm-mmx-ft{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-mmx-ft.dm-sw{display:block}
:root:not([data-style="soft"]) .dm-mmx-m.dm-new:not(:where([data-style="soft"],[data-style="soft"] *)){background:repeating-linear-gradient(135deg,transparent 0 5px,color-mix(in srgb,var(--dm-accent-mark) 14%,transparent) 5px 6px),var(--dm-sel)}
:root:not([data-style="soft"]) .dm-mmx-m.dm-use:not(:where([data-style="soft"],[data-style="soft"] *)){animation:dm-mmxusebp .9s steps(3,end)}
@keyframes dm-mmxusebp{0%{background:color-mix(in srgb,var(--dm-accent) 18%,transparent)}100%{background:transparent}}
:root:not([data-style="soft"]) .dm-mmx-m.dm-use .dm-mmx-src:not(:where([data-style="soft"],[data-style="soft"] *)){animation:dm-mmxbp .9s steps(1,end)}
@keyframes dm-mmxbp{0%,50%{box-shadow:inset 0 0 0 1px var(--dm-k)}51%,100%{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-rail) 50%,transparent)}}
memory-panel.htmlHTML · 10 lines
<div class="card bare" data-c="memory-panel">
  <div class="stage-in">
    <div class="mmx panel" data-reveal="flat">
      <div class="mmx-h"><span class="mmx-ic"><svg class="i s"><use href="#i-book"/></svg></span><div class="mmx-ht"><b>Team memory</b><span class="mmx-sub"><span class="muted mmx-st">7 memories</span><span class="mmx-srcs" aria-hidden="true"></span></span></div><div class="seg2 mmx-tab" role="group" aria-label="Filter"><button aria-pressed="true" data-v="all">All</button><button aria-pressed="false" data-v="brand">Brand</button><button aria-pressed="false" data-v="process">Process</button></div></div>
      <div class="mmx-list"></div>
      <div class="mmx-f" aria-live="polite"><span class="mmx-fi" aria-hidden="true"><svg class="i xs"><use href="#i-spark"/></svg></span><span class="mmx-ft">Used in 14 answers this week</span></div>
    </div>
  </div>
</div>
memory-panel.cssHTML · 61 lines
/* ---------- Memory panel ---------- */
/* each memory shows where it came from, as that source's own mark (Slack, Figma, GitHub, Notion, Linear, Drive) or an icon
   (something said in chat, an edit), and its kind in the kind's colour (--k). A memory the agent learns arrives shimmering
   while it works the rule out, then sits on the accent's tint with a New tag; the memories an answer uses pulse once and say so */
.mmx{padding:var(--s-3);display:flex;flex-direction:column;gap:var(--s-2);min-height:330px}
/* on a card the panel sits centred in the room kept for its tallest moment, and glides up as a new memory opens its row */
.stage-in:has(>.mmx){display:flex;flex-direction:column;justify-content:center}
.mmx-h{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.mmx-ic{width:32px;height:32px;border-radius:10px;background:var(--pp-bg);color:var(--pp);display:grid;place-items:center;flex:none}
.mmx-ht{display:flex;flex-direction:column;flex:1;min-width:0;font-size:13.5px}
.mmx-ht b{font-weight:500}
.mmx-sub{display:flex;align-items:center;gap:var(--s-2);min-width:0}
.mmx-st{font-size:12px;white-space:nowrap}
/* the apps it has learned from, as a row of their marks */
.mmx-srcs{display:inline-flex;align-items:center;gap:3px}
.mmx-srcs span{width:18px;height:18px;border-radius:5px;display:grid;place-items:center;flex:none;background:var(--surface);color:var(--fg);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--rail) 50%,transparent)}
.mmx-srcs .brand{width:11px;height:11px}
@media (max-width:600px){.mmx-srcs{display:none}}
.mmx-tab button{font-size:12px;height:26px;padding:0 9px}
.mmx-list{display:flex;flex-direction:column}
.mmx-m{display:flex;align-items:flex-start;gap:10px;padding:var(--s-2) 6px;border-radius:10px;font-size:13px;line-height:1.5;transition:background .3s}
@media (hover:hover) and (pointer:fine){.mmx-m:hover{background:var(--row)}}
.mmx-src{width:28px;height:28px;margin-top:1px;border-radius:8px;display:grid;place-items:center;flex:none;background:var(--surface);color:var(--fg2);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--rail) 50%,transparent)}
.mmx-src .brand{width:15px;height:15px;color:var(--fg)}
.mmx-src .spin{width:12px;height:12px}
.mmx-b{flex:1;min-width:0;display:flex;flex-direction:column}
.mmx-m .t{outline:0;border-radius:5px;padding:0 3px;margin:0 -3px}
.mmx-m .t[contenteditable="true"]{background:var(--surface);box-shadow:0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent)}
.mmx-m .t[contenteditable="true"]:focus{box-shadow:0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
/* under it: its kind in the kind's colour, then where it came from */
.mmx-meta{display:flex;align-items:center;gap:6px;min-width:0;font-size:11.5px;color:var(--muted);white-space:nowrap}
.mmx-meta>*{flex:none}
.mmx-meta .k{color:var(--k);font-weight:500}
.mmx-meta .s{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
.mmx-meta .tag{height:17px;padding:0 5px;font-size:11px}
.mmx-meta .tag.nw{background:var(--accent-bg);color:var(--accent-fg,var(--fg))}
.mmx-m .acts{display:flex;opacity:0;transition:opacity .2s}
.mmx-m:focus-within .acts{opacity:1}
@media (hover:hover) and (pointer:fine){.mmx-m:hover .acts{opacity:1}}
.mmx-m .ib{width:24px;height:24px}
.mmx-m.learn .acts{visibility:hidden}
.mmx-m.new{background:var(--sel)}
/* a memory an answer uses pulses once with the accent's tint, and its source lifts */
.mmx-m.use{animation:mmxuse 1.4s var(--ease)}
.mmx-m.use .mmx-src{animation:mmxlift .6s var(--ease)}
@keyframes mmxuse{0%{background:color-mix(in srgb,var(--accent) 16%,transparent)}100%{background:transparent}}
@keyframes mmxlift{40%{transform:translateY(-2px) scale(1.08)}}
.mmx-gone{display:flex;align-items:center;justify-content:space-between;gap:var(--s-2);padding:6px var(--s-2);border-radius:10px;font-size:12.5px;color:var(--muted);background:var(--chip)}
.mmx-gone button{color:var(--accent-fg,var(--accent));font-weight:500;font-size:12.5px}
/* the line at the foot: how often the memory is used, or the answer it is helping with now */
.mmx-f{display:flex;align-items:center;gap:var(--s-2);margin-top:auto;padding:10px 6px 2px;border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent);font-size:12px;color:var(--muted)}
.mmx-fi{display:grid;place-items:center;flex:none;color:var(--fg2)}
.mmx-fi .brand{width:14px;height:14px}
.mmx-ft{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mmx-ft.sw{display:block}
:root:not([data-style="soft"]) .mmx-m.new:not(:where([data-style="soft"],[data-style="soft"] *)){background:repeating-linear-gradient(135deg,transparent 0 5px,color-mix(in srgb,var(--accent-mark) 14%,transparent) 5px 6px),var(--sel)}
:root:not([data-style="soft"]) .mmx-m.use:not(:where([data-style="soft"],[data-style="soft"] *)){animation:mmxusebp .9s steps(3,end)}
@keyframes mmxusebp{0%{background:color-mix(in srgb,var(--accent) 18%,transparent)}100%{background:transparent}}
:root:not([data-style="soft"]) .mmx-m.use .mmx-src:not(:where([data-style="soft"],[data-style="soft"] *)){animation:mmxbp .9s steps(1,end)}
@keyframes mmxbp{0%,50%{box-shadow:inset 0 0 0 1px var(--k)}51%,100%{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--rail) 50%,transparent)}}
memory-panel.jsHTML · 71 lines
DM.def('memory-panel', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{book}', I('book')).replace('{spark}', I('spark','xs'));
  // where a memory came from: an app's own mark, or an icon for something said in chat or an edit
  const ICON = { chat:'comment', edit:'edit' };
  const mark = s => BRAND[s] || I(ICON[s] || s, 'xs');
  const KIND = { brand:['Brand', 'var(--pl)'], process:['Process', 'var(--pt)'] };
  let M = [
    ['brand','Primary buttons use the accent blue, never black','figma','Pricing review in Figma, Sep 12'],
    ['brand','Headlines stay under eight words','chat','You told the agent, Aug 30'],
    ['process','Ask Maya before publishing to the shared library','slack','A thread in #design-system, Sep 4'],
    ['brand','Card radius is 16px on web, 12px in the app','github','tokens.css on GitHub, Sep 18'],
    ['process','Run the launch checklist before every PR','notion','Launch checklist in Notion, Sep 20'],
    ['process','File design bugs in the Design QA project','linear','DES-214 in Linear, Sep 26'],
    ['brand','No em dashes in product copy','drive','Voice and tone guide in Drive, Oct 2']
  ].map((m, i) => ({ id:i, k:m[0], t:m[1], from:m[2], s:m[3] }));
  const list = $('.mmx-list', r), st = $('.mmx-st', r), srcs = $('.mmx-srcs', r), fi = $('.mmx-fi', r), ft = $('.mmx-ft', r);
  let f = 'all', nid = 100;
  // the count, and the apps the memories came from
  const count = () => { swap(st, M.length + (M.length === 1 ? ' memory' : ' memories')); srcs.innerHTML = [...new Set(M.map(m => m.from).filter(s => BRAND[s]))].map(s => `<span>${BRAND[s]}</span>`).join(''); };
  const meta = m => `<span class="k">${KIND[m.k][0]}</span><span aria-hidden="true">·</span><span class="s">${m.s}</span>${m.isNew ? '<span class="tag nw">New</span>' : ''}${m.used ? '<span class="tag nw">Used</span>' : ''}`;
  const rowOf = m => $(`.mmx-m[data-id="${m.id}"]`, list);
  function item(m){
    const el = html(`<div class="mmx-m${m.isNew ? ' new' : ''}${m.learn ? ' learn' : ''}" data-id="${m.id}" style="--k:${KIND[m.k][1]}"><span class="mmx-src" aria-hidden="true">${m.learn ? '<span class="spin"></span>' : mark(m.from)}</span><span class="mmx-b"><span class="t${m.learn ? ' shim' : ''}" spellcheck="false">${m.t}</span><span class="mmx-meta">${meta(m)}</span></span><span class="acts"><button class="ib ed" aria-label="Edit" data-tip="Edit">${I('edit','xs')}</button><button class="ib rm" aria-label="Forget" data-tip="Forget">${I('trash','xs')}</button></span></div>`);
    const t = $('.t', el);
    $('.ed', el).onclick = () => { t.contentEditable = 'true'; t.focus({ preventScroll:true }); const s = document.getSelection(); s.selectAllChildren(t); s.collapseToEnd();
      const done = () => { t.contentEditable = 'false'; m.t = t.textContent.trim() || m.t; m.s = 'Edited by you, just now'; m.from = 'edit'; t.textContent = m.t; update(m); count(); };
      t.onkeydown = e => { if (e.key === 'Enter'){ e.preventDefault(); t.blur(); } }; t.onblur = done; };
    $('.rm', el).onclick = () => { const i = M.indexOf(m); M.splice(i, 1); count();
      const g = html(`<div class="mmx-gone"><span>Forgot “${m.t.slice(0, 32)}${m.t.length > 32 ? '…' : ''}”</span><button>Undo</button></div>`); el.replaceWith(g); enter(g);
      const tm = setTimeout(() => { g.classList.add('leave'); setTimeout(() => g.remove(), 320); }, 4000);
      $('button', g).onclick = () => { clearTimeout(tm); M.splice(i, 0, m); count(); const n = item(m); g.replaceWith(n); enter(n); }; };
    return el;
  }
  // a memory's row catches up with its data: its source, its text once it has been learned, its tags and its highlight
  function update(m, pulse){
    const el = rowOf(m); if (!el) return;
    const src = $('.mmx-src', el), t = $('.t', el);
    if (!m.learn && el.classList.contains('learn')){ el.classList.remove('learn'); t.classList.remove('shim'); swap(t, m.t); src.innerHTML = mark(m.from); enter(src.firstElementChild); }
    else if (t.contentEditable !== 'true') src.innerHTML = mark(m.from);
    el.classList.toggle('new', !!m.isNew);
    $('.mmx-meta', el).innerHTML = meta(m);
    if (pulse){ el.classList.remove('use'); void el.offsetWidth; el.classList.add('use'); }
  }
  function paint(){ list.innerHTML = ''; M.filter(m => f === 'all' || m.k === f).forEach(m => list.appendChild(item(m))); stagger([...list.children], 40); }
  // a row that arrives opens its own height, so the panel grows smoothly instead of jumping
  const grow = el => { if (REDUCE) return; const cs = getComputedStyle(el), gap = parseFloat(getComputedStyle(list).rowGap) || 0, ease = getComputedStyle(r).getPropertyValue('--ease').trim() || 'ease';
    el.animate([{ height:'0px', paddingTop:'0px', paddingBottom:'0px', marginBottom:-gap + 'px', opacity:0, filter:'blur(4px)', overflow:'hidden' }, { height:cs.height, paddingTop:cs.paddingTop, paddingBottom:cs.paddingBottom, marginBottom:'0px', opacity:1, filter:'blur(0px)', overflow:'hidden' }], { duration:560, easing:ease }); };
  seg($('.mmx-tab', r), v => { f = v; paint(); });
  count(); paint();
  // the preview: the agent learns a rule from your edit in Figma, then drafts an email in Gmail with three memories, which
  // pulse as it reads them. Every state lives in the memories' data, so switching tabs keeps it; taking over settles it
  c.run(async () => {
    await c.step(2200);
    const m = { id:nid++, k:'brand', t:'Learning from your edit in Pricing v4', from:'figma', s:'Just now', learn:true };
    const learnt = () => Object.assign(m, { learn:false, t:'Use “Start free” instead of “Sign up” on CTAs', s:'Your edit in Pricing v4, just now' });
    c.taken(() => { if (m.learn){ learnt(); update(m); } if (ft.classList.contains('shim')){ ft.classList.remove('shim'); fi.innerHTML = I('spark','xs'); swap(ft, 'Used in 14 answers this week'); } });
    M.unshift(m); count(); if (f === 'brand' || f === 'all'){ const el = item(m); list.prepend(el); grow(el); }
    await c.step(1500);
    learnt(); m.isNew = true; update(m);
    await c.step(2600);
    m.isNew = false; m.s = 'Your edit in Figma, today'; update(m);
    await c.step(1000);
    fi.innerHTML = BRAND.gmail; enter(fi.firstElementChild);
    ft.classList.add('shim'); swap(ft, 'Drafting the launch email in Gmail');
    await c.step(1100);
    let n = 0;
    for (const id of [m.id, 1, 6]){ const x = M.find(y => y.id === id); if (!x) continue; x.used = true; n++; update(x, true); await c.step(700); }
    ft.classList.remove('shim'); swap(ft, `Used ${n} ${n === 1 ? 'memory' : 'memories'} in the launch email draft`);
  });
});
06

Questions

What people ask about it.

How do I save an edited memory to my backend?
Use onEdit. It is called with the updated memory after an edit, with its new text, the editedSource line and the edit icon. If the text is left empty, the old text is kept.
When should I delete a forgotten memory for good?
onForget fires as soon as someone clicks Forget, and onRestore fires if they click Undo within undoFor ms. Wait out that window, or handle both callbacks, before deleting it on the server.
How do I show a memory the agent just learned?
Add it to the front of memories with learning: true while the agent works it out, then replace its text and set learning to false and isNew to true. It grows into the list and is highlighted; set isNew back to false later to clear the highlight.
How do I show which memories an answer used?
Set used: true on each one as the agent reads it, and set activity to say what it is writing, with live: true while it works. Each memory pulses as it turns on and keeps its Used tag until you set used back to false.
Can I use my own categories?
Yes. Pass kinds with a value, a tab label and a colour for each; every memory points to one by its kind. Pick a colour that reads as text, since the kind's label is written in it.
07

More in Workspace

Navigation, search, settings, models and automations around the agent.

Automation canvasA trigger, a few agent steps, and a run you can watch flow through.
Canvas toolbarA floating toolbar for a workflow canvas: run, pick a tool, comment, fit and zoom.
Command paletteJump anywhere or hand a task to the agent from one box.
Model compareRun one prompt on three models side by side and keep the best.
Agent settingsHow much the agent may do alone, with a save bar for changes.
Tools and connectionsThe tools the agent can reach, and how much it may do with each.
Knowledge sourcesThe files, sites and folders the agent knows, and how far each is indexed.
Scheduled runsTasks the agent runs on a schedule, with their last result and a switch.
All 11 in Workspace →