Introduced 1 production defect in 180 days, median 6 days to fix.
'use client';
import type { AnyExtension, Editor, Range } from '@tiptap/core';
import { Extension } from '@tiptap/core';
import {
Slack,
MessageSquare,
Zap,
Plug,
Bot,
Search,
Sparkles,
Globe,
Mail,
KanbanSquare,
Table2,
FileText,
} from 'lucide-react';
import { IntegrationNode, type IntegrationKind } from './IntegrationNode';
import { TriggerNode, DEFAULT_TRIGGER_CONFIG } from './TriggerNode';
import { PostApiNode, DEFAULT_POST_API_CONFIG } from './PostApiNode';
import { createHashMention } from './HashMention';
import {
isPlaybookDocumentKind,
type HashMentionItem,
type PlaybookDocumentKind,
} from './HashMentionList';
import type { SlashCommandItem } from '@/components/slash-command/types';
import { ReferenceNode } from './ReferenceNode';
import { createReferenceMention } from './ReferenceMention';
import { createFileLinkSuggestionExtension } from '../file-link';
import type { FileLinkSuggestionItem } from '../file-link';
import { TriggerRefNode } from './TriggerRefNode';
import { BlockCalloutNode } from './BlockCalloutNode';
import { FileLinkNode } from '../file-link/FileLinkNode';
import type { ReferenceOption } from './references';
import {
AlwaysLoadedSection,
OnDemandSection,
GlobalSection,
StageSection,
CrossCuttingSection,
StageEntry,
StageExit,
StageInstructions,
CompositeSection,
ScopeSection,
} from './structure/index';
import { playbookStructureGuard, compositePlaybookStructureGuard } from './structure/structure-guard';
export interface PlaybookExtensionsOptions {
/** Open the document a `@` reference chip points to. */
onOpenReference?: (path: string) => void;
/** Open the document a `[[doc:uuid]]` ref chip (fileLink) points to, by id. */
onOpenDocument?: (documentId: string) => void;
/** AOP slug supplying each stage's colour + icon for the stage header. */
aopId?: string | null;
/**
* The AOP's owner. Threaded rather than read from the member picker: these
* nodes also render in the `/brain` document editor, which mounts no picker
* and can be showing any member's document.
*/
ownerUserId?: string | null;
/** Override the default static `@` mention options with a dynamic list. */
referenceOptions?: ReferenceOption[];
/** Custom search for `[[` file-link suggestions — scopes results to this AOP's docs. */
searchFileLinks?: (query: string) => Promise<{ id: string; label: string; path: string; documentType: string; description: string | null; lastOpened: Date | string | null; updatedAt: Date | string }[]>;
/**
* Begin the create-subagent flow (open the name dialog). The typed `#`/`/`
* range is deleted before this fires; the caller owns the dialog, mutation,
* `<ref>` insertion, and navigation.
*/
onCreateSubagent?: (editor: Editor, range: Range) => void;
/** Board / table / resource-document creation, from the same `#` and `/` menus. */
onCreateDocument?: (kind: PlaybookDocumentKind, editor: Editor, range: Range) => void;
}
/** True when the current selection sits inside a `TriggerNode` (any depth). */
function isInsideTrigger(editor: Editor): boolean {
const { $from } = editor.state.selection;
for (let depth = $from.depth; depth > 0; depth--) {
if ($from.node(depth).type.name === TriggerNode.name) return true;
}
return false;
}
/**
* The scope (`'org' | 'user'`) of the nearest enclosing `ScopeSection`, or null
* when the cursor isn't inside one (the single-scope editors have no scope cards).
* Used to route a new subagent to the org vs user `subagents/` folder.
*/
export function scopeAtCursor(editor: Editor): 'org' | 'user' | null {
const { $from } = editor.state.selection;
for (let depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (node.type.name === ScopeSection.name) {
return node.attrs.scope === 'org' ? 'org' : 'user';
}
}
return null;
}
/**
* References a freshly created subagent from the playbook. A subagent only runs
* when it sits inside a `<trigger>` block, so:
* - inside a trigger already → insert the `triggerRef` panel at the cursor;
* - otherwise → wrap it in a fresh trigger (default `any`/event, picker open) so
* it isn't an orphan ref that shows in the roster but never fires.
*
* Either way it is a PANEL, not a chip: a ref in a trigger is an agent the trigger runs,
* and it carries the instruction for that trigger in its own body.
*/
export function insertSubagentRef(editor: Editor, documentId: string) {
if (isInsideTrigger(editor)) {
editor.chain().focus().insertContent(triggerRefNode(documentId)).run();
return;
}
editor
.chain()
.focus()
.insertContent({
type: TriggerNode.name,
attrs: { config: JSON.stringify(DEFAULT_TRIGGER_CONFIG), autoOpen: true },
content: [triggerRefNode(documentId)],
})
.run();
}
/**
* The block panel a ref becomes inside a `<trigger>`: agent above, its instruction for
* THIS trigger below. Content is deliberately EMPTY — the placeholder under it is CSS on
* an empty node (TriggerRefNode.tsx), because seeded text would round-trip into the XML
* and every ref would ship with the placeholder as its real instruction.
*/
function triggerRefNode(documentId: string, attrs?: Record<string, unknown>) {
return { type: TriggerRefNode.name, attrs: { documentId, ...attrs } };
}
/**
* The `+ Instructions` half of a ref chip that sits inside a `<trigger>`.
*
* Every ref written before per-trigger instructions existed is self-closing, so it parses
* to the inline chip and has nowhere to put an instruction. This is the way in: press it
* and the chip becomes the block panel, with the caret already in its instruction area.
*
* Offered ONLY inside a trigger. A ref in `<always-loaded>` or `<on-demand>` is a document
* the agent reads, not an agent a trigger fires, and there is no trigger for it to have an
* instruction for.
*
* The conversion has two shapes because a paragraph can hold more than the chip:
* - chip alone in its paragraph (what every round-tripped `<ref/>` looks like) → replace
* the paragraph, so no empty paragraph is left behind;
* - chip among prose (`Only score external calls @coach`) → delete just the chip and put
* the panel after the paragraph, leaving the prose intact as the block's own note.
*/
function triggerChipAction(
editor: Editor,
documentId: string,
pos: number | undefined,
): { label: string; ariaLabel: string; run: () => void } | null {
if (!documentId || pos === undefined) return null;
const $pos = editor.state.doc.resolve(pos);
let insideTrigger = false;
for (let depth = $pos.depth; depth > 0; depth--) {
if ($pos.node(depth).type.name === TriggerNode.name) {
insideTrigger = true;
break;
}
}
if (!insideTrigger) return null;
return {
label: 'Instructions',
ariaLabel: 'Add instructions for this trigger',
run: () => {
const node = editor.state.doc.nodeAt(pos);
if (!node || node.type.name !== FileLinkNode.name) return;
// Carry `section`/`when` across. They are per-ref attributes the compiler reads, and
// dropping them here would turn "add an instruction" into "silently lose a scope".
const attrs = {
documentId,
...(node.attrs.section ? { section: node.attrs.section } : {}),
...(node.attrs.when ? { when: node.attrs.when } : {}),
};
const $chip = editor.state.doc.resolve(pos);
const parent = $chip.parent;
const chipIsAlone = parent.childCount === 1 && parent.firstChild === node;
const chain = editor.chain().focus();
if (chipIsAlone) {
const from = $chip.before($chip.depth);
const to = from + parent.nodeSize;
chain.deleteRange({ from, to }).insertContentAt(from, triggerRefNode(documentId, attrs));
} else {
const after = $chip.after($chip.depth);
chain
.deleteRange({ from: pos, to: pos + node.nodeSize })
.insertContentAt(after - node.nodeSize, triggerRefNode(documentId, attrs));
}
chain.run();
},
};
}
/**
* Inserts a ref at the cursor, choosing the shape by WHERE the cursor is: a `triggerRef`
* panel inside a trigger block, the inline `fileLink` chip everywhere else. One function
* because the choice is never the caller's — it is a property of the position.
*/
function insertRefAtCursor(editor: Editor, range: Range | null, documentId: string) {
const chain = editor.chain().focus();
if (range) chain.deleteRange(range);
if (isInsideTrigger(editor)) {
chain.insertContent(triggerRefNode(documentId)).run();
return;
}
chain
.insertContent([{ type: FileLinkNode.name, attrs: { documentId } }, { type: 'text', text: ' ' }])
.run();
}
/**
* References a freshly created board, table or resource document — a bare `<ref>` at the
* cursor, and deliberately NOT wrapped in a trigger the way `insertSubagentRef` wraps its.
*
* The two cases are opposites. A subagent ref outside a trigger is an orphan: it shows in
* the roster and never fires. A board ref INSIDE one is a resource the playbook would try
* to run on a schedule. Same chip, opposite defaults, so they are two functions rather than
* one with a flag nobody would get right at the call site.
*/
export function insertDocumentRef(editor: Editor, documentId: string) {
editor
.chain()
.focus()
.insertContent([{ type: FileLinkNode.name, attrs: { documentId } }, { type: 'text', text: ' ' }])
.run();
}
/**
* Inserts a block-level trigger callout with its config editor open by default
* (`autoOpen`), so the user lands straight in trigger selection.
*/
function insertTrigger(editor: Editor, range: Range) {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: TriggerNode.name,
attrs: { config: JSON.stringify(DEFAULT_TRIGGER_CONFIG), autoOpen: true },
content: [{ type: 'paragraph' }],
})
.run();
}
/**
* Inserts a block-level Post API config component with its editor open by default.
* Doc-only — the config persists in the node's `data-post-api-config` attribute.
*/
function insertPostApi(editor: Editor, range: Range) {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: PostApiNode.name,
attrs: { config: JSON.stringify(DEFAULT_POST_API_CONFIG), autoOpen: true },
})
.run();
}
/**
* Inserts an inline integration chip. Slack and MCP open their config surface on
* insert (`autoOpen`); iMessage is presentational. A trailing space lets typing
* continue after the chip.
*/
function insertIntegration(editor: Editor, range: Range, kind: IntegrationKind) {
const autoOpen = kind === 'slack' || kind === 'mcp';
editor
.chain()
.focus()
.deleteRange(range)
.insertContent([
{
type: IntegrationNode.name,
attrs: { kind, ...(autoOpen ? { autoOpen: true } : {}) },
},
{ type: 'text', text: ' ' },
])
.run();
}
/**
* Inserts the node chosen from the `#` menu. `trigger` becomes a block-level
* configuration callout, the integration kinds become inline chips, and
* `subagent` hands off to the caller's create-subagent flow.
*/
function onHashMentionSelect(
item: HashMentionItem,
editor: Editor,
range: Range,
onCreateSubagent?: (editor: Editor, range: Range) => void,
onCreateDocument?: (kind: PlaybookDocumentKind, editor: Editor, range: Range) => void,
) {
if (item.kind === 'subagent') {
editor.chain().focus().deleteRange(range).run();
onCreateSubagent?.(editor, range);
return;
}
if (isPlaybookDocumentKind(item.kind)) {
// The `#…` text goes now, before the dialog opens — leaving it would put a stray "#board"
// in the document behind a modal the user might cancel.
editor.chain().focus().deleteRange(range).run();
onCreateDocument?.(item.kind, editor, range);
return;
}
if (item.kind === 'trigger') {
insertTrigger(editor, range);
return;
}
if (item.kind === 'post_api') {
insertPostApi(editor, range);
return;
}
insertIntegration(editor, range, item.kind);
}
const PLAYBOOK_SLASH_GROUP = 'Playbook';
/**
* The same trigger/integration/subagent inserts as the `#` menu, surfaced as the
* top group of the `/` slash menu (slack, subagent, trigger, imessage, MCP).
* Pass to `<Document extraSlashCommands={…}>`. `onCreateSubagent` (optional)
* begins the create-subagent flow.
*/
export function createPlaybookSlashCommands(
opts: {
onCreateSubagent?: (editor: Editor, range: Range) => void;
onCreateDocument?: (kind: PlaybookDocumentKind, editor: Editor, range: Range) => void;
} = {},
): SlashCommandItem[] {
// Canonical order: Trigger, Slack, iMessage, MCP, Post API, Web search, Enrich, Subagent.
return [
{
title: 'Trigger',
description: 'Configure an agent trigger',
icon: Zap,
group: PLAYBOOK_SLASH_GROUP,
keywords: ['trigger', 'on', 'event', 'when', 'cron', 'schedule', 'webhook'],
command: (editor, range) => insertTrigger(editor, range),
},
{
title: 'Slack',
description: 'Reference a Slack channel',
icon: Slack,
group: PLAYBOOK_SLASH_GROUP,
keywords: ['slack', 'channel', 'integration'],
command: (editor, range) => insertIntegration(editor, range, 'slack'),
},
{
title: 'iMessage',
description: 'Reference an iMessage thread',
icon: MessageSquare,
group: PLAYBOOK_SLASH_GROUP,
keywords: ['imessage', 'message', 'sms', 'text'],
command: (editor, range) => insertIntegration(editor, range, 'imessage'),
},
{
title: 'MCP',
description: 'Configure an MCP server',
icon: Plug,
group: PLAYBOOK_SLASH_GROUP,
keywords: ['mcp', 'server', 'tool', 'integration'],
command: (editor, range) => insertIntegration(editor, range, 'mcp'),
},
{
title: 'Post API',
description: 'POST to a configured endpoint',
icon: Globe,
group: PLAYBOOK_SLASH_GROUP,
keywords: ['post', 'api', 'webhook', 'http', 'endpoint', 'request'],
command: (editor, range) => insertPostApi(editor, range),
},
{
title: 'Web search',
description: 'Search the web',
icon: Search,
group: PLAYBOOK_SLASH_GROUP,
keywords: ['web', 'search', 'google', 'browse', 'lookup'],
command: (editor, range) => insertIntegration(editor, range, 'web_search'),
},
{
title: 'Enrich',
description: 'Enrich a person or company',
icon: Sparkles,
group: PLAYBOOK_SLASH_GROUP,
keywords: ['enrich', 'enrichment', 'person', 'company', 'research'],
command: (editor, range) => insertIntegration(editor, range, 'enrich'),
},
{
title: 'Email me',
description: 'Email the user a message',
icon: Mail,
group: PLAYBOOK_SLASH_GROUP,
keywords: ['email', 'mail', 'notify', 'send', 'me'],
command: (editor, range) => insertIntegration(editor, range, 'email'),
},
{
title: 'Subagent',
description: 'Create a new subagent',
icon: Bot,
group: PLAYBOOK_SLASH_GROUP,
keywords: ['subagent', 'agent', 'sub-agent'],
command: (editor, range) => {
editor.chain().focus().deleteRange(range).run();
opts.onCreateSubagent?.(editor, range);
},
},
// The document kinds, in the same order the `#` menu lists them, so the two menus are
// one vocabulary rather than two that happen to overlap.
{
title: 'Board',
description: 'Create a board this agent works',
icon: KanbanSquare,
group: PLAYBOOK_SLASH_GROUP,
keywords: ['board', 'kanban', 'tickets', 'roadmap', 'cards'],
command: (editor, range) => {
editor.chain().focus().deleteRange(range).run();
opts.onCreateDocument?.('board', editor, range);
},
},
{
title: 'Table',
description: 'Create a table this agent fills',
icon: Table2,
group: PLAYBOOK_SLASH_GROUP,
keywords: ['table', 'grid', 'spreadsheet', 'columns', 'rows'],
command: (editor, range) => {
editor.chain().focus().deleteRange(range).run();
opts.onCreateDocument?.('table', editor, range);
},
},
{
title: 'Document',
description: 'Create a resource document',
icon: FileText,
group: PLAYBOOK_SLASH_GROUP,
keywords: ['document', 'doc', 'resource', 'notes', 'guide'],
command: (editor, range) => {
editor.chain().focus().deleteRange(range).run();
opts.onCreateDocument?.('doc', editor, range);
},
},
];
}
/**
* Inserts a `@` reference chip followed by a space so typing can continue — UNLESS the
* cursor is inside a trigger and the option resolves to a real document, in which case it
* becomes a `triggerRef` panel instead.
*
* The condition has two halves because `@` is path-addressed, not id-addressed:
* `@crm-updater` and `@next-steps` are system tokens with no document behind them at all,
* and the static fallbacks in `REFERENCE_OPTIONS` are paths nobody has resolved yet. A
* `<ref>` needs an id, so only an option that carries one can become a panel; everything
* else stays the chip it has always been.
*/
function onReferenceSelect(item: ReferenceOption, editor: Editor, range: Range) {
if (item.documentId && isInsideTrigger(editor)) {
insertRefAtCursor(editor, range, item.documentId);
return;
}
editor
.chain()
.focus()
.deleteRange(range)
.insertContent([
{ type: ReferenceNode.name, attrs: { path: item.path } },
{ type: 'text', text: ' ' },
])
.run();
}
/** `[[` file-link picks inside a trigger become panels, for the same reason `@` does. */
function onFileLinkInsert(editor: Editor, range: Range, item: FileLinkSuggestionItem): boolean {
if (!isInsideTrigger(editor)) return false;
insertRefAtCursor(editor, range, item.id);
return true;
}
/**
* Disables bold (Mod-b) and italic (Mod-i) keyboard shortcuts in playbook
* mode. Playbook XML has no inline formatting marks; intercepting the
* shortcuts prevents accidental `**bold**` or `_italic_` from being written
* into the document where they would be interpreted as literal asterisks.
*/
const DisableBoldItalic = Extension.create({
name: 'playbookDisableBoldItalic',
addKeyboardShortcuts() {
return {
'Mod-b': () => true,
'Mod-i': () => true,
};
},
});
/**
* TipTap extensions that make a document a Playbook: the `#` trigger/integration
* mention + its nodes, the `@` reference mention + `referenceNode`, the
* block-level callout used for stage Exit criteria, and a guard that disables
* bold/italic shortcuts. Drop into `<Document>` via `extraExtensions`. Pass
* `onOpenReference` to make `@` chips open their target document.
*/
export function createPlaybookExtensions(
options: PlaybookExtensionsOptions = {},
): AnyExtension[] {
return [
// Structural container nodes — must be first so TipTap schema recognises
// them before content nodes try to nest inside them.
AlwaysLoadedSection,
OnDemandSection,
GlobalSection,
StageSection.configure({ aopId: options.aopId ?? null, ownerUserId: options.ownerUserId ?? null }),
CrossCuttingSection,
StageEntry,
StageExit,
StageInstructions,
playbookStructureGuard,
// Inline / block content nodes
// `<ref id>` chips inside sections/triggers; click opens the target document.
FileLinkNode.configure({
onOpen: options.onOpenDocument ?? null,
// The chip only sprouts a second half inside a trigger — see triggerChipAction.
chipAction: ({ editor, documentId, pos }) => triggerChipAction(editor, documentId, pos),
}),
// The block-level agent panel a ref becomes inside a `<trigger>`. Registered AFTER
// FileLinkNode because both shapes of `<ref>` coexist in one document, forever.
TriggerRefNode.configure({ onOpen: options.onOpenDocument ?? null }),
IntegrationNode,
TriggerNode.configure({ aopId: options.aopId ?? null, ownerUserId: options.ownerUserId ?? null }),
PostApiNode,
BlockCalloutNode,
createHashMention({
onSelect: (item, editor, range) =>
onHashMentionSelect(item, editor, range, options.onCreateSubagent, options.onCreateDocument),
}),
ReferenceNode.configure({ onOpen: options.onOpenReference ?? null }),
createReferenceMention({ onSelect: onReferenceSelect, options: options.referenceOptions }),
createFileLinkSuggestionExtension({
...(options.searchFileLinks ? { search: options.searchFileLinks } : {}),
insert: onFileLinkInsert,
}),
DisableBoldItalic,
];
}
/**
* Extension set for the unified composite playbook editor. Same building blocks
* as `createPlaybookExtensions`, plus the `compositeSection` group + `scopeSection`
* sub-card wrappers, and the composite structure guard (which also locks those
* two wrappers from deletion). Used by a non-collaborative editor, so the inner
* structural nodes still render exactly as in the single-doc editor.
*/
export function createCompositePlaybookExtensions(
options: PlaybookExtensionsOptions = {},
): AnyExtension[] {
return [
// Composite wrappers first, then the same structural + content nodes.
CompositeSection.configure({
aopId: options.aopId ?? null,
ownerUserId: options.ownerUserId ?? null,
}),
ScopeSection,
AlwaysLoadedSection,
OnDemandSection,
GlobalSection,
StageSection.configure({ aopId: options.aopId ?? null, ownerUserId: options.ownerUserId ?? null }),
CrossCuttingSection,
StageEntry,
StageExit,
StageInstructions,
compositePlaybookStructureGuard,
FileLinkNode.configure({
onOpen: options.onOpenDocument ?? null,
// The chip only sprouts a second half inside a trigger — see triggerChipAction.
chipAction: ({ editor, documentId, pos }) => triggerChipAction(editor, documentId, pos),
}),
// The block-level agent panel a ref becomes inside a `<trigger>`. Registered AFTER
// FileLinkNode because both shapes of `<ref>` coexist in one document, forever.
TriggerRefNode.configure({ onOpen: options.onOpenDocument ?? null }),
IntegrationNode,
TriggerNode.configure({ aopId: options.aopId ?? null, ownerUserId: options.ownerUserId ?? null }),
PostApiNode,
BlockCalloutNode,
createHashMention({
onSelect: (item, editor, range) =>
onHashMentionSelect(item, editor, range, options.onCreateSubagent, options.onCreateDocument),
}),
ReferenceNode.configure({ onOpen: options.onOpenReference ?? null }),
createReferenceMention({ onSelect: onReferenceSelect, options: options.referenceOptions }),
createFileLinkSuggestionExtension({
...(options.searchFileLinks ? { search: options.searchFileLinks } : {}),
insert: onFileLinkInsert,
}),
DisableBoldItalic,
];
}