rich-document-actions
RichDocument action toolkit for markdown and interactive content outside live chat. Use when adding an action or content source, wiring a remote action surface, putting actions on a MarkdownStream/Bas
Files1
View on GitHubSKILL.md.claude/skills/rich-document-actions/SKILL.md
GitHubRichDocument 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
▼
@ai-matrx/rich-content provider ← Layer 2: the action registry. Module-scope
@ai-matrx/chat register*.ts Map<id, RichDocumentAction>, populated by the
+ web actions/handlers/*.ts package's registerChatMessageActions() and the
web-only handlers.
│ 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"; // WHAT
actionsPosition: "below" |
"above" |
"top-right" |
"top-left" |
"middle-right" |
"middle-left"; // WHERE (default "below")
actionsBehavior: "always" | "hover-only"; // VISIBILITY (default "always")
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"], // hide specific ids
extra: [ /* custom RichDocumentAction[] — see below */ ],
callbacks: { onFullPrint, onRequestDelete }, // host-supplied hooks some actions call
}}
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 // boolean, or:
enableContextMenu={{ extra: [...], exclude: [...] }} // context-menu-only actions
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:
// In the header chrome:
<RichDocumentActionSurface surfaceId={`note-detail-${noteId}`} variant="bar" fallback={null} />
// In the body:
<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:
../aidream/apps/shared/chat/src/agents/components/working-document/WorkingDocumentPanel.tsx(headless provider + header surface). The live/notesheader intentionally has no remote consumer, soNotesViewomitsactionsSurfaceIdand 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):
// Mounted once, always (gate on whatever "active" means for your surface):
<RichDocumentActionProvider content={liveText} source={mySource} surfaceId={mySurfaceId} />
// The toolbar, anywhere:
<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: ../aidream/apps/shared/chat/src/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
Where actions live: the message actions are in @ai-matrx/chat (source ../aidream/apps/shared/chat/src/agents/components/messages-display/rich-document/actions/): descriptors in register.ts, register-w2a.ts, register-w2b.ts, register-w3c.ts, register-w3d.ts and descriptors/ (id, label, icon, visible load with the menu), run bodies in bodies/ (each run imports its body on first click). registerChatMessageActions() registers them once, register-if-absent per id. This app has ONE entry, ensureWebRichDocumentRegistrations (features/rich-content-host/rich-document-registrations.ts): it calls that and loads the web-only handlers in features/rich-document/actions/handlers/ (notes-and-comments, apply-to-source, attach-artifact-to-chat, code-block-apply-to-file). Every non-chat surface that needs the actions (right-click, notes, tests) calls it too.
- Default: add it to the package so every host inherits it. Add a descriptor to the right
register-*.ts(ordescriptors/*.ts) and its body tobodies/*.ts; reach host-only things (dialogs, windows, routes) through the host seams, never by importing the app. Publish, thenpnpm sync:matrx-packageshere. - Only a context a package host cannot serve gets a web handler: add a module under
features/rich-document/actions/handlers/, import it inhandlers/index.ts, and register throughregisterActionIfAbsent(handlers/register-if-absent.ts), neverregisterActiondirectly. The web list only shrinks:actions/web-action-ratchet.json+web-action-ratchet.test.tsfail on a web id the package registers or a new web-only id. - The descriptor shape:
{
id: "my-action", // unique
label: "Do the thing", // string OR (ctx) => string for dynamic labels
icon: SomeLucideIcon,
iconColor: "text-blue-500 dark:text-blue-400", // optional
category: "save", // feedback|copy|export|save|edit|share|creator|app|admin
supportedSources: "*", // or ["chat-message", "note", ...]
renderSlot: "overflow", // "primary" (inline) | "overflow" (⋯) | "both"
order: 5,
visible: (ctx) => true, // optional
disabled: (ctx) => false, // optional; return { reason } for a tooltip
run: async (ctx) => {
// ctx: content, source, metadata, dispatch, isAuthenticated, isAdmin,
// isCreator, surfaceKey, instanceKey(prefix), sourceAdapter, callbacks, extensions
await doSomething(ctx.content);
toast.success("Done"); // handlers OWN their toasts/dialogs
},
}
- Place it in the menu hierarchy: add its id to a section in
menuStructure(top-level array for promoted items, or a submenu'sactionIds). 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 genericeditaction works for chat (editMessage), notes (NotesAPI.update), etc. - Auth-gated actions: call
requireAuth(ctx, key, featureName, description)at the top ofrun; it opens the authGate overlay and returns false when signed out. - Overlay
instanceIds: usectx.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", // MUST already exist in features/overlays/
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) infeatures/rich-document/types.ts. - Create
actions/sources/<type>.tsimplementingContentSourceAdapter— at minimuminstanceKeyPrefix(source); addedit/delete/reRunif the source supports them (they gate theedit/deleteactions' visibility). - Register it in
actions/sources/index.ts(SOURCE_ADAPTERSmap). - If
save-to-taskshould link a parent, add the source totaskEntityOf()in the package'sbodies/w3d-actions.ts. - Add the new
typeto theRichDocumentActionIdsource-compatibility expectations in any actions that should support it (supportedSources).
Invariants & gotchas
- No functions / content / React elements in the
richDocumentActionSurfacesslice. 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()) thereact-hooks/purityrule flags. rendererhas 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 incomponents/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/MarkdownRendererandcomponents/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 forchat-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'sContentActionBar) → 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-checkandpnpm 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 (nonce handshake, two steps):
openssl rand -hex 16 > .dev-login-nonce, then/api/dev-login?nonce=<that value>&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: @ai-matrx/rich-content `rich-document/actions/provider` — registerAction / getAction / resolveActions)
│ ├── utils.ts ← requireAuth, getErrorMessage, extractFirstCodeBlock, buildTaskTitle, resolveActionLabel
│ ├── handlers/*.ts ← the web-only handlers (registerActionIfAbsent; index.ts imports them)
│ └── 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 ../aidream/apps/shared/chat/src/agents/components/working-document/WorkingDocumentPanel.tsx (headless provider + remote header toolbar in every editor mode).