| name | rich-document-actions |
| description | Use whenever a task touches the RichDocument system at matrx-frontend — rendering markdown/interactive content WITH an action toolkit (copy, save to notes/scratch/task/code, save as file, HTML preview, copy HTML page, email, print, edit, open in full-screen editor, fork, delete, TTS, analyze/debug, convert to broker, add to docs) on any surface that is NOT the live chat AssistantActionBar. Triggers on `features/rich-document/**`, any `<RichDocument>` or `<RichDocumentActionSurface>` usage, the `richDocumentActionSurfaces` Redux slice, `registerAction`, `ContentSource`, `ContentSourceAdapter`, the `enableContextMenu` prop, or anything mentioning "rich document", "action bar on markdown", "save to task/notes from <surface>", "remote action surface", "action surface id", "context menu on content", "add an action to the menu", "multi-layer overflow menu", or "put actions on a markdown preview". Read this BEFORE adding an action, adding a content source, wiring a remote surface, or swapping a `MarkdownStream` / `BasicMarkdownContent` consumer to gain actions. NOT for the live chat message bar (that is `AssistantActionBar` + `messageActionRegistry.ts` — see the `overlay-system` skill's "Message actions (chat)" section). |
RichDocument Actions
Canonical how-to for the RichDocument system — the wrapper that pairs the markdown content engine with a configurable, pluggable action toolkit. Deep reference: features/rich-document/FEATURE.md. Master design/plan: ~/.claude/plans/if-you-review-the-snappy-stallman.md.
The most important context
MarkdownStream / BasicMarkdownContent are NOT a thin react-markdown wrapper. They are a multi-thousand-line content engine that renders interactive flashcards (with AI integrations), live diagrams, wired task lists, code surfaces, tool-call visualizations, realtime feeds, classification analyzers, plan viewers, and more. The "markdown" name is historical. Never reimplement it, never "replace it with a plugin," never fork it. RichDocument wraps it — it does not replace it.
RichDocument's job is the action layer, not rendering. It forwards content to the engine and adds a surface of actions (copy / save-to-notes / save-to-task / print / html-preview / edit / …) that used to exist only on the chat AssistantActionBar. The whole point: every surface that shows content can now offer the same depth of interaction with one component.
Mental model — three layers + two extras
<RichDocument> ← Layer 1: wrapper. Forwards to the engine,
├─ MarkdownStream (the engine) renders the chosen action variant.
└─ action variant (bar / menu / …)
│ looks up handlers by id
▼
actions/registry.ts ← Layer 2: the action registry. Module-scope
actions/handlers/*.ts Map<id, RichDocumentAction>, populated by
self-registering handler modules.
│ source-specific edit/delete
▼
actions/sources/*.ts ← per-source adapters (ContentSourceAdapter)
<RichDocumentActionSurface ← Layer 3 (REMOTE): renders a RichDocument's
surfaceId="…" /> actions somewhere else in the tree (a header,
a sidebar) — connected by surfaceId.
enableContextMenu ← Extra: lazy, streaming-safe right-click menu.
runtime/providerBridge.ts ← Extra: module-scope bridge that lets the remote
surface invoke handlers WITHOUT functions in Redux.
Load-bearing invariant: the richDocumentActionSurfaces Redux slice stores only pure metadata (action ids, labels, icon names, disabled flags) — never handlers, content, callbacks, or React elements. Handlers live in the module-scope registry and are looked up by id at click time; live content is read through the providerBridge's getCtx() getter. This is the same pattern the overlay system uses (callbackManager). Do not put functions in the slice.
TASK: Use RichDocument on a page (the common case)
Swap a bare <MarkdownStream content={x}/> for:
import { RichDocument } from "@/features/rich-document/RichDocument";
import type { ContentSource } from "@/features/rich-document/types";
<RichDocument
content={text}
source={{ type: "note", noteId }} // drives action visibility + save-to-task linking
actionsVariant="bar" // bar | mini-bar | menu | icon-only | remote | none
/>;
RichDocument is "use client" (the engine is dynamic({ ssr:false })). Server components cannot render it directly — render it from a client child.
Pick a source
The discriminated union in features/rich-document/types.ts. The type drives which actions show and how save-to-task links a parent:
| Source | Use for |
|---|
{ type: "chat-message", messageId, conversationId, streamRequestId? } | a cx_message |
{ type: "note", noteId } | a note |
{ type: "prompt-result", executionId, promptId? } | a prompt run result |
{ type: "artifact", artifactId } | an artifact |
{ type: "scraper-result", runId } | a scraper/research run |
{ type: "working-document", conversationId, kind, documentId? } | the per-conversation working doc / scratchpad (kind: "working" | "scratch"); edits persist via persistWorkingDocumentContentThunk |
{ type: "raw" } | generic content with no entity link (most read-only previews) |
raw is fine for read-only displays — you still get copy/save/print/html-preview/etc.; only the parent link on save-to-task is absent.
Pick a variant + position + behavior (three orthogonal axes)
actionsVariant: "bar" | "mini-bar" | "menu" | "icon-only" | "remote" | "none";
actionsPosition: "below" |
"above" |
"top-right" |
"top-left" |
"middle-right" |
"middle-left";
actionsBehavior: "always" | "hover-only";
Conventions used across the codebase:
- Main content output (note preview, research report) →
variant="bar" (or "mini-bar"), position="below", behavior="always".
- Compact card / preview / overlay →
variant="icon-only", position="top-right", behavior="hover-only" (an unobtrusive ⋯ that fades in on hover).
- Tight footprint (toast) →
variant="mini-bar".
There is no "hover-menu" variant — it was removed. Express it as { icon-only, top-right, hover-only }.
Tune which actions appear
All built-ins are included by default; trim/extend via the actions prop:
actions={{
exclude: ["announcements", "preferences"],
extra: [ ],
callbacks: { onFullPrint, onRequestDelete },
}}
Source-incompatible actions hide themselves automatically (e.g. fork-at-message only shows for chat-message). The overflow menu is a multi-layer menu (top-level promoted items + Save / Copy as / Export / Edit / Creator / Admin / App submenus, mobile = drawer + accordion) driven by variants/shared/menuStructure.ts.
Add a right-click context menu (optional)
enableContextMenu
enableContextMenu={{ extra: [...], exclude: [...] }}
Lazy (the menu chunk loads only on first right-click) and streaming-safe (yields to the native browser menu while isStreamActive). This is the extension point for future per-surface right-click functionality.
TASK: Render actions in a different location (remote surface)
Put the bar in a page header / toolbar while the content lives elsewhere:
<RichDocumentActionSurface surfaceId={`note-detail-${noteId}`} variant="bar" fallback={null} />
<RichDocument
content={text}
source={{ type: "note", noteId }}
actionsVariant="remote"
actionsSurfaceId={`note-detail-${noteId}`}
/>
- Use a per-entity surfaceId (
note-detail-${id}) so fast A→B navigation never collides.
- The surface renders nothing (
fallback) when no provider is registered (e.g. the body is in a non-preview mode) — so an empty header row is fine.
- The registry is a stack: if two RichDocuments target one surfaceId, the most-recently-mounted wins, and out-of-order unmount during navigation stays correct. Don't add your own last-wins logic.
- Real example to copy:
features/agents/components/working-document/WorkingDocumentPanel.tsx (headless provider + header surface). The live /notes header intentionally has no remote consumer, so NotesView omits actionsSurfaceId and keeps preview/split actions inline.
Surface draws its OWN content (an editor)? Don't mount a hidden RichDocument just to register the toolbar — it would double-render the heavy engine. Use the headless RichDocumentActionProvider (renders null):
<RichDocumentActionProvider content={liveText} source={mySource} surfaceId={mySurfaceId} />
<RichDocumentActionSurface surfaceId={mySurfaceId} variant="bar" fallback={null} />
This is how the toolbar appears in every editor mode (plain / split / wysiwyg / preview), not just the one mode that mounts a RichDocument. Real example: features/agents/components/working-document/WorkingDocumentPanel.tsx (provider + header bar) + WorkingDocumentControls.tsx (compact menu surface). It shares useActionSurfaceProvider with RichDocument, so behavior never drifts.
MatrxSplit has opt-in passthrough props (actionsSource / actionsVariant / actionsPosition / actionsBehavior / actionsSurfaceId / actionsExclude) — when actionsSource is set it swaps its preview pane to RichDocument (lazily), including the streaming-safe right-click menu. Pattern in components/matrx/MatrxSplit.tsx.
TASK: Add a new action
- Pick the right handler module under
features/rich-document/actions/handlers/ (copy.ts, save.ts, export.ts, print.ts, edit.ts, feedback.ts, creator.ts, fullscreen-editor.ts, stubs.ts, app.ts, server-api.ts) or add a new one and import it in actions/handlers/index.ts.
- Call
registerAction at module load:
import { registerAction } from "../registry";
registerAction({
id: "my-action",
label: "Do the thing",
icon: SomeLucideIcon,
iconColor: "text-blue-500 dark:text-blue-400",
category: "save",
supportedSources: "*",
renderSlot: "overflow",
order: 5,
visible: (ctx) => true,
disabled: (ctx) => false,
run: async (ctx) => {
await doSomething(ctx.content);
toast.success("Done");
},
});
- Place it in the menu hierarchy: add its id to a section in
features/rich-document/variants/shared/menuStructure.ts (top-level array for promoted items, or a submenu's actionIds). If you skip this, it falls into a trailing "extras" group.
- Source-specific behavior goes through the adapter, never hard-coded: call
ctx.sourceAdapter.edit({ newContent, source, dispatch }) rather than dispatching a chat thunk directly. That's how one generic edit action works for chat (editMessage), notes (NotesAPI.update), etc.
- Auth-gated actions: call
requireAuth(ctx, key, featureName, description) from actions/utils.ts at the top of run; it opens the authGate overlay and returns false when signed out.
- Overlay
instanceIds: use ctx.instanceKey("prefix") so two documents on one page don't collide.
For a per-surface, app-specific action, prefer actions={{ extra: [...] }} at the call site over a global registry entry.
extra actions that open an overlay
run: ({ dispatch, source, instanceKey }) =>
dispatch(openOverlay({
overlayId: "noteWindow",
instanceId: instanceKey("note-window"),
data: { noteId: (source as Extract<ContentSource,{type:"note"}>).noteId },
})),
The shape is { overlayId, data, instanceId } — not { component, props }. To add a new overlay, follow features/overlays/FEATURE.md (the 3-file process). Invoke the overlay-system skill.
TASK: Add a new content source type
- Add the variant to
ContentSource (and, if it carries chat-style baggage, SourceExtensions) in features/rich-document/types.ts.
- Create
actions/sources/<type>.ts implementing ContentSourceAdapter — at minimum instanceKeyPrefix(source); add edit / delete / reRun if the source supports them (they gate the edit / delete actions' visibility).
- Register it in
actions/sources/index.ts (SOURCE_ADAPTERS map).
- If
save-to-task should link a parent, add the source to sourceToEntityType() in actions/handlers/save.ts.
- Add the new
type to the RichDocumentActionId source-compatibility expectations in any actions that should support it (supportedSources).
Invariants & gotchas
- No functions / content / React elements in the
richDocumentActionSurfaces slice. Metadata only. (Bridge + module registry hold the live stuff.)
"use client" is mandatory on anything rendering RichDocument; the engine is SSR-disabled.
- No
useMemo / useCallback / React.memo — the React Compiler is on. Refs are read only inside event handlers / effects, never during render (react-hooks/refs). Avoid impure calls (Date.now()) the react-hooks/purity rule flags.
renderer has no "auto". The basic vs configurable engines preprocess differently; silent swaps diverge output. Default is the standard engine path; pick explicitly only when needed.
- Do NOT wrap engine-internal block renderers (
ArtifactBlock, MarkdownPreviewBlock, StructuredPlanViewer, anything in components/mardown-display/) in RichDocument — they run inside the engine, so wrapping recurses infinitely. The parent message/surface provides actions at the outer level.
MarkdownRenderer (components/ai/MarkdownRenderer and components/mardown-display/MarkdownRenderer) is a SEPARATE lightweight react-markdown primitive, not the heavy engine — out of scope; don't migrate those to RichDocument reflexively.
- The live chat bar is off-limits here.
AssistantActionBar + messageActionRegistry.ts (1.6k lines) still power chat and carry intricate chat-only behavior (fork-vs-delete dialog, local thumbs state, density hover, group aggregation, the per-conversation menu gate). RichDocument's handlers were ported from it and are equivalent for chat-message, but swapping the live bar is a supervised consolidation (plan Phase 4), not a casual change. Until then the duplication is intentional.
When NOT to use RichDocument
- Engine-internal blocks → recursion (see above).
- A surface that already owns a bespoke action bar you must keep (e.g.
research/DocumentViewer's ContentActionBar) → swapping changes behavior; leave it or migrate deliberately.
- Pure non-interactive micro-text where a menu would be noise.
- Live streaming chat messages → that's the chat pipeline's territory.
Verification
pnpm type-check and pnpm eslint features/rich-document/ <your-files> — must be clean (the feature itself carries zero errors; pre-existing react-compiler warnings in unrelated files are not yours to fix here).
- Browser: render the surface, open the ⋯ menu — confirm the multi-layer hierarchy, that source-incompatible actions are hidden, and that a submenu expands. For a remote surface: confirm the body is chrome-free and the header bar opens a menu that operates on the body's live content. For context menu: DevTools Network shows no context-menu chunk until first right-click; during an active stream the native browser menu shows instead.
- Auto-login for testing:
/api/dev-login?token=${DEV_LOGIN_TOKEN}&next=/<route>.
File map
features/rich-document/
├── RichDocument.tsx ← the wrapper (engine + variant switch, positioning, context-menu mount; delegates registration to useActionSurfaceProvider)
├── RichDocumentActionSurface.tsx ← remote renderer (reads slice top-of-stack → bridge → variant)
├── RichDocumentActionProvider.tsx ← headless: registers the toolkit for a surfaceId WITHOUT the engine (renders null)
├── types.ts ← ContentSource, RichDocumentAction(Context), variant/position/behavior, adapter, specs
├── actions/
│ ├── registry.ts ← registerAction / getAction / resolveActions
│ ├── utils.ts ← requireAuth, getErrorMessage, extractFirstCodeBlock, buildTaskTitle, resolveActionLabel
│ ├── handlers/*.ts ← self-registering action modules (index.ts imports them all)
│ └── sources/*.ts ← per-source ContentSourceAdapter + SOURCE_ADAPTERS map
├── variants/
│ ├── ActionBar / MiniActionBar / MenuVariant ← inline variants
│ ├── OverflowMenu.tsx ← ⋯ dropdown (desktop) → MobileActionDrawer (mobile)
│ ├── MobileActionDrawer.tsx ← bottom sheet + accordion
│ ├── ContextMenu.tsx ← lazy right-click menu (controlled dropdown at cursor)
│ └── shared/{menuStructure,DropdownMenuTree,PrimaryButtons,runAction,categories}.ts
├── runtime/
│ ├── useActionSurfaceProvider.ts ← shared registration brain (RichDocument + RichDocumentActionProvider; buildContext/specs/provider+bridge effects)
│ ├── providerBridge.ts ← module-scope getCtx/actions registry (no functions in Redux)
│ └── ContextMenuMount.tsx ← lightweight, streaming-safe, lazy-loads ContextMenu
└── redux/actionSurfacesSlice.ts ← surfaceId → provider stack (metadata only)
Real migrated consumers to copy from: features/notes/components/NoteEditorCore.tsx + NotesView.tsx (inline preview/split actions), features/tool-call-visualization/renderers/web-research/WebResearchOverlay.tsx (icon-only hover), and features/agents/components/working-document/WorkingDocumentPanel.tsx (headless provider + remote header toolbar in every editor mode).