playbookExtensions.ts22.5 KBView on GitHub

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,
  ];
}