The admin UI for creating and editing content entries across collections, accessible at /admin/content.
The Content workspace renders a three-pane shell — explorer sidebar, document canvas, and settings panel — using AdminWorkspaceCanvasLayout. Collections map to data_tables of kind: 'postType'; entries are data_rows. The body field stores and round-trips through a single Tiptap/ProseMirror document serialized to markdown.
- Entry:
ContentPage.tsx→AdminWorkspaceCanvasLayout— left sidebar, canvas, right settings panel. - Body editor:
TiptapBodyEditor— one ProseMirror document, not a block list. Body persists as markdown text in thebodycell. - Inline marks: bubble menu (B / I / code / strike / link). Block inserts: slash menu (
/) + notch quick-actions. - Canvas modes:
write(bare editor surface) andlive(entry rendered inside its template with real site styles). - Settings panel:
ContentSettingsPanel— entry-specific; hidden when no entry is selected. Reopened via the top-right notch when collapsed. - Hooks:
useContentWorkspace(CRUD + selection),useContentEntryDraft(field state + save/publish),useContentMediaPicker(media modal + featured media). - AI assistant:
ContentAgentMountdocks the shared Agent Panel in the content rail.useContentToolBridge, mounted byContentPage, exposes the live workspace to both the built-in agent and scoped MCP relay even when the panel is closed, so writes mutate the open draft/editor state rather than stale database rows.
ContentPage.tsx
├── AdminWorkspaceCanvasLayout
│ ├── contentSidebar → ContentSidebar
│ │ ├── ContentExplorerPanel ← collection + entry list, context menus
│ │ ├── MediaExplorerPanel ← shared media panel in the content rail
│ │ └── ContentAgentMount ← AI assistant panel
│ ├── contentCanvas → ContentDocumentCanvas
│ │ ├── CanvasNotch ← quick-insert actions (Heading, Text, Media, Data token)
│ │ ├── ContentModeToggle ← write / live mode switch
│ │ ├── TiptapBodyEditor ← ProseMirror body surface
│ │ │ ├── BodyBubbleMenu ← floating marks toolbar (B/I/code/strike/link)
│ │ │ ├── BodyFloatingMenu ← block-type switcher on empty lines
│ │ │ ├── BodySlashMenu ← slash (/) command menu
│ │ │ └── MediaNodeToolbar ← toolbar on selected media nodes
│ │ └── LiveCanvas ← template-rendered live preview
│ └── contentRightPanel → ContentSettingsPanel (entry-specific; absent when no entry)
TiptapBodyEditor.tsx is a single Tiptap 3 / ProseMirror document — not a list of independent block widgets.
The canonical body is a markdown string in the body cell of the data row. The editor converts on each change:
markdown text (stored) ←→ ProseMirror JSON (in-editor)
Round-trip functions live in src/core/markdown/markdownDocument.ts:
markdownToProseMirrorDoc(md)— parse markdown → ProseMirror node treeproseMirrorDocToMarkdown(doc)— serialize ProseMirror node tree → markdown
The serializer produces stable, idempotent output. Existing entries load with no migration; the grammar is a strict superset of what was stored before.
| Category | Examples |
|---|---|
| Inline marks | **bold**, *italic*, `code`, ~~strike~~, [link](url) |
| Headings | ## H2, ### H3, #### H4 (# H1 normalised to ## H2 — H1 is reserved for the title) |
| Lists | - / * bullets, 1. ordered, nested via 2-space indents |
| Block quote | > |
| Code block | triple-backtick fence with optional language |
| Horizontal rule | --- |
| Tables | GFM pipe-table syntax |
| Media |  for images, @[video](src) for videos |
| Data tokens | {source.field} — plain inline text in the editor; resolved by the publisher at render time |
| Surface | How |
|---|---|
| Bubble menu | Appears on text selection — bold / italic / code / strike / link |
Slash menu (/) |
Contextual command menu at the caret — headings, lists, quote, code block, divider, table, media, data token |
| Canvas notch | Top-center quick-action buttons — Heading, Text, Media, Insert data token. It does not show the Site editor's module picker. |
| Input rules | # → H1 (normalised), ## → H2, **x** → bold, - → bullet, ``` → code block, etc. |
ContentDocumentCanvas holds a ref to a TiptapBodyEditorHandle:
interface TiptapBodyEditorHandle {
focusStart: () => void
insertText: (text: string) => void
insertMedia: (attrs: MediaAttributes) => void
appendBlock: (kind: 'heading' | 'paragraph') => void
}The parent calls this handle from the title-Enter handler, the notch actions, and the media-picker confirmation. The editor owns its own ProseMirror state; the parent never holds the doc tree.
The content editor is a document surface (one ProseMirror doc), not a visual editor canvas. Block reorder is done via cut/paste or keyboard move. Drag handles are the right primitive for the visual editor, not a writing surface.
Collections are data_tables with kind: 'postType'. The four system tables (posts, pages, components, layouts) are present on every install; additional collections are created via ContentCollectionCreateDialog.
ContentExplorerPanel renders all collections and their entries. Per-entry operations (publish, convert to draft, rename, duplicate, delete, move to collection) are exposed via context menus (ContextMenu per row).
useContentWorkspace (src/admin/pages/content/hooks/useContentWorkspace.ts) owns all collection and entry CRUD, loading state, and selection. It does not hold draft field state — that lives in useContentEntryDraft.
ContentSettingsPanel is the right-hand panel. It is entry-specific: it renders only when an entry is selected (contentRightPanel is undefined when workspace.selectedEntry is null).
AdminWorkspaceCanvasLayout treats an absent contentRightPanel as "no right panel available",
so a persisted open state never reserves an empty right rail on a fresh install or after the last entry is cleared.
The panel exposes: status selector, slug, author (if the user has canEditAnyContent), collection (move entry), SEO title, SEO description, featured media — followed by the collection's custom (non-built-in) fields, rendered generically through the Data workspace's CellEditorRenderer (context="detail"). Relation fields open the shared RelationPickerDialog (target-table rows resolved for display by useRelationTargetRows); media fields carry their own picker. pageTree / fieldSchema fields are excluded — their cells hold whole documents, not values. Custom values live in useContentEntryDraft's customCells and save through the same Save / Publish lifecycle as the built-ins.
When the panel is collapsed and an entry is selected, AdminWorkspaceCanvasLayout renders a compact notch in the top-right corner of the canvas (data-testid="content-settings-notch") with a button labelled "Open settings panel". Clicking it reopens the panel without changing the selected entry. See docs/editor.md — "Admin shell layout" for the notch's implementation context.
ContentModeToggle switches between:
- Write (
contentMode === 'write'): bare editor surface (ContentDocumentCanvaswithTiptapBodyEditor). - Live (
contentMode === 'live'): entry rendered inside its template viaLiveCanvas, with real site styles and inline editing.
The mode switch is client-only. The markdown body is the source of truth in both modes.
| Hook | Source | Owns |
|---|---|---|
useContentWorkspace |
hooks/useContentWorkspace.ts |
Collection list, entry list, selection, CRUD operations, error state |
useContentEntryDraft |
hooks/useContentEntryDraft.ts |
In-memory field state (title, body, slug, featuredMediaId, seoTitle, seoDescription, plus customCells for the collection's non-built-in fields), save / publish / status-change handlers |
useContentMediaPicker |
hooks/useContentMediaPicker.ts |
Media picker modal open/close, featured media asset hydration, body media insert |
The Content workspace has its own content chat scope, mounted as the agent panel in ContentSidebar when the current user has ai.chat.
ContentAgentMount creates a fresh per-page AgentSlice store (contentAgentStore.ts) for the visible chat panel. Independently, ContentPage always mounts useContentToolBridge, which registers a ContentBridgeHandle and the content MCP stream for as long as the workspace is open. The handle reads the current collections, selected entry, draft fields, schema, and current user via refs so either caller sees the same state the user sees. Tool writes go through that handle and then through useContentWorkspace / useContentEntryDraft, which keeps unsaved body/title/SEO/media changes and sidebar selection in sync. content_set_active_document loads an uncached row by id, switches across post-type collections without waiting for the target sidebar list, and commits the workspace and draft focus before it returns so an immediately following write targets the selected document.
The server registers 15 content-scope tools:
| Group | Tools |
|---|---|
| Server reads | content_list_collections, content_get_collection_schema, content_list_documents, content_get_document, content_search_documents, content_list_users, content_list_media |
| Browser writes/navigation | content_create_document, content_delete_document, content_set_document_status, content_set_document_field, content_set_document_fields, content_set_document_author, content_set_active_document, content_set_active_collection |
Body content is exchanged with the model as markdown. The browser bridge converts it to/from the Tiptap document when applying field writes, so the persisted body cell remains the same markdown source of truth used by the manual editor.
content_create_document always creates a draft. Publishing or scheduling is deliberately a follow-up content_set_document_status call so the dedicated content.publish.own / content.publish.any capability gate cannot be bypassed through creation.
| Pattern | Why |
|---|---|
| Mutating block state via a block ID list | There are no blocks — one ProseMirror document, not a ContentBlock[] list |
Calling insertText / insertMedia without checking bodyEditorRef.current |
The ref is null before the editor mounts |
Storing ProseMirror JSON in the body cell |
Body is always markdown text; the editor does the conversion |
Adding useMemo / useCallback in ContentPage or its hooks |
React Compiler handles memoization; the only exception is async handlers extracted to module scope to avoid compiler bail-out (see useContentEntryDraft) |
Opening the settings panel via a forced setPropertiesPanel({ collapsed: false }) on mount |
The persisted layout is the source of truth; only user actions (selecting an entry, clicking the notch) open the panel |
| Mutating content-agent writes directly against repositories | The browser bridge owns writes so unsaved draft state and the open Tiptap editor do not desync |
- docs/editor.md — admin shell layouts,
AdminWorkspaceCanvasLayout, notch implementation - docs/features/data-workspace.md — parallel workspace pattern for raw data tables
- docs/features/content-storage.md —
data_tables+data_rowsschema, field types - docs/features/media.md — media workspace,
MediaPickerModal, upload pipeline - docs/features/agent.md — shared AI runtime, content-scope tools, and browser bridge
- Source-of-truth files:
src/admin/pages/content/ContentPage.tsx— workspace mount pointsrc/admin/pages/content/agent/ContentAgentMount.tsx— content Agent Panel mountsrc/admin/pages/content/agent/useContentToolBridge.ts— always-mounted live handle + scoped MCP bridge registrationsrc/admin/pages/content/agent/contentBridge.ts— content agent browser-tool dispatchersrc/admin/pages/content/agent/contentBridgeHandle.ts— live content workspace bridge handlesrc/admin/pages/content/TiptapBodyEditor.tsx— body editorsrc/admin/pages/content/hooks/useContentWorkspace.ts— collection/entry statesrc/admin/pages/content/hooks/useContentEntryDraft.ts— field draft statesrc/core/markdown/markdownDocument.ts— markdown ↔ ProseMirror round-tripsrc/admin/layouts/AdminWorkspaceCanvasLayout/AdminWorkspaceCanvasLayout.tsx— workspace shell + notch