WorkspaceMemory panel
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callMemory 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add memory-panelReact
npx designme add memory-panel --reactThe 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.
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.
- Filter tabs for All plus one per entry in
kinds. Under each memory its kind'slabelis written in the kind'scolor, followed by itssource, like "A thread in #design-system, Sep 4". - Each memory shows where it came from:
brandfor an app's own mark (google, github, figma, slack, notion, linear, gmail or drive), oriconfor anything else. Wider than a phone, the marks of the apps the memories came from sit beside the count unlessshowSourcesis false. learningshimmers a memory's text, shows a spinner where its source goes and hides Edit and Forget. When it is learned, the text crossfades to the rule and the source's mark comes in.- A memory marked
isNewsits on the accent's tint with a New tag (newLabel), and grows into the list when it arrives while the panel is open. - A memory marked
usedpulses once with the accent's tint as it turns on, its source lifts, and it keeps a Used tag (usedLabel). activitysets the line at the foot, like the answer the agent is writing with its memory now: itstext, an optionalbrandoricon(a spark by default), andliveto shimmer it.- Edit turns a memory into editable text; Enter or leaving the field saves it, gives it the
editedSourceline and an edit icon in place of its source, and callsonEdit. - Forget removes a memory and leaves an Undo in its place for
undoForms (4000 by default), callingonForgetand, if undone,onRestore. - The count under the title updates as memories come and go, worded by
countLabel. - In Blueprint the new memory's tint is hatched and the pulse steps in hard beats, with the source's edge blinking in the kind's colour instead of lifting.
- Sits flat by default; set
bareto false to give it its own card.
- The filter tabs are a role="group" labelled "Filter", with aria-pressed on the active tab.
- Each memory's kind, source and tags are written as text; the source marks are
aria-hidden, so the colours and logos are never the only way to tell them apart. - The line at the foot is
aria-live="polite", so a change in what the agent is doing is read out. - The Edit and Forget icon buttons have aria-labels and tooltips that also show on keyboard focus, and are disabled while a memory is still being learned.
- Pressing Enter while editing a memory saves it.
- With reduced motion, the grow animation for a new memory is skipped, and the shimmer, the pulse and the lift stop.
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>
);
}
Props
Everything MemoryPanel takes.
| Prop | Type | What it does |
|---|---|---|
title | ReactNode | Like "Team memory" |
icon | IconName | |
kindsrequired | MemoryKind[] | |
memories | MemoryItem[] | Every memory, newest first. Leave it out to let Edit and Forget keep their own list |
defaultMemories | MemoryItem[] | |
onMemoriesChange | (memories: MemoryItem[]) => void | |
filter | string | The tab that is showing: 'all' or a kind's value |
defaultFilter | string | |
onFilterChange | (filter: string) => void | |
allLabel | string | The first tab |
countLabel | (count: number) => string | The count under the title, like "7 memories" |
showSources | boolean | Shows the marks of the apps the memories came from beside the count. On by default |
activity | MemoryActivity | The line at the foot, like what the agent is answering with its memory now |
editedSource | string | The source an edited memory gets |
newLabel | string | The tag on a memory marked `isNew` |
usedLabel | string | The tag on a memory marked `used` |
onEdit | (memory: MemoryItem) => void | Called with the memory after an edit |
onForget | (memory: MemoryItem) => void | Called when a memory is forgotten. It can still come back with Undo for `undoFor` ms |
onRestore | (memory: MemoryItem) => void | Called when Undo brings a forgotten memory back |
undoFor | number | How long Undo stays, in ms |
bare | boolean | Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own |
className | string |
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`);
});
});
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, theeditedSourceline and the edit icon. If the text is left empty, the old text is kept. - When should I delete a forgotten memory for good?
onForgetfires as soon as someone clicks Forget, andonRestorefires if they click Undo withinundoForms. 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
memorieswithlearning: truewhile the agent works it out, then replace its text and setlearningto false andisNewto true. It grows into the list and is highlighted; setisNewback to false later to clear the highlight. - How do I show which memories an answer used?
- Set
used: trueon each one as the agent reads it, and setactivityto say what it is writing, withlive: truewhile it works. Each memory pulses as it turns on and keeps its Used tag until you setusedback to false. - Can I use my own categories?
- Yes. Pass
kindswith a value, a tab label and a colour for each; every memory points to one by itskind. Pick a colour that reads as text, since the kind's label is written in it.
More in Workspace
Navigation, search, settings, models and automations around the agent.