PlaybookTriggerNode.tsx16.1 KBView on GitHub
'use client';

import { Node, mergeAttributes } from '@tiptap/core';
import { Fragment, type Node as PMNode, type Schema } from '@tiptap/pm/model';
import { useState } from 'react';
import { Pencil } from 'lucide-react';
import { createDeferredAutoConvertPlugin } from '../deferred-auto-convert';
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
import type {
  NodeViewProps,
  MarkdownToken,
  MarkdownParseHelpers,
  MarkdownRendererHelpers,
  MarkdownLexerConfiguration,
} from '@tiptap/core';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
  TriggerSection,
  type TriggerConfig,
  type CrmEventType,
} from '@/modules/aop/components/TriggerConfigEditor';
import { cronLabel } from '@/modules/aop/components/cron';
import { usePlaybookAop } from './usePlaybookAop';

export interface PlaybookTriggerOptions {
  /** AOP whose field definitions power the field-change editor. */
  aopId: string | null;
  /** The AOP's owner, so a teammate's document resolves against them. */
  ownerUserId: string | null;
}

/** `0 9 * * 1-5 America/Los_Angeles` → "Weekdays at 9 am · Los Angeles" */
function humanCron(arg: string): string {
  const parts = arg.trim().split(/\s+/);
  const last = parts[parts.length - 1] ?? '';
  const hasTz = last.includes('/');
  const expr = hasTz ? parts.slice(0, -1).join(' ') : arg.trim();
  const tz = hasTz ? ` · ${last.split('/').pop()?.replace(/_/g, ' ')}` : '';
  return `${cronLabel(expr)}${tz}`;
}

/**
 * `playbookTrigger` — a Notion-style callout node for the backend trigger grammar
 * `[on:<tag>]` … `[/on]` (see apps/server/src/services/playbook/trigger-parser.ts).
 *
 * It renders as an editable callout (header pill + body content area), exactly
 * like the original mock trigger, and round-trips losslessly to the markdown
 * delimiters. Stored content arrives as plain text (the server reconstructs Y.js
 * without this client node), so an auto-convert plugin wraps `[on:…]`…`[/on]`
 * regions into the node on load — mirroring the FileLinkNode approach.
 */

interface TriggerStyle {
  label: string;
  color: string;
}

function triggerStyle(rawTag: string): TriggerStyle {
  const tag = rawTag.trim();
  const head = tag.split(/[:\s]/)[0];
  const arg = tag.slice(head.length).replace(/^[:\s]+/, '').trim();
  switch (head) {
    case 'any':
      return { label: 'Always', color: '#6b7280' };
    case 'email':
      return { label: 'On email', color: '#2563eb' };
    case 'meeting':
      return { label: 'After meeting', color: '#16a34a' };
    case 'slack':
      return { label: 'On Slack', color: '#7c3aed' };
    case 'crm-sync':
      return { label: 'On CRM sync', color: '#ea580c' };
    case 'before-meeting':
      return { label: arg ? `${arg} min before meeting` : 'Before meeting', color: '#0d9488' };
    case 'cron':
      return { label: arg ? humanCron(arg) : 'Cron', color: '#9333ea' };
    case 'field-change':
      return { label: arg || 'Field change', color: '#d97706' };
    default:
      return { label: tag, color: '#6b7280' };
  }
}

/** `[on:<tag>]` event head → the AOP editor's `CrmEventType`, and back. */
const TAG_TO_EVENT: Record<string, CrmEventType> = {
  email: 'email',
  meeting: 'meeting',
  slack: 'slack',
  'crm-sync': 'external_crm',
  call: 'call',
  note: 'note',
};
const EVENT_TO_TAG: Record<CrmEventType, string> = {
  email: 'email',
  meeting: 'meeting',
  slack: 'slack',
  external_crm: 'crm-sync',
  call: 'call',
  note: 'note',
};

/**
 * Parse an `[on:<tag>]` string into the {@link TriggerConfig} the AOP editor
 * understands. Inverse of {@link configToTag}. The `if:` clause on event tags is
 * not represented in the config (the editor manages event type + schedule +
 * field-change, matching the AOP event editor) — {@link TriggerView} carries it
 * through separately so editing other aspects of an event trigger doesn't lose
 * the condition.
 */
function tagToConfig(rawTag: string): TriggerConfig {
  const tag = rawTag.trim();
  const head = tag.split(/[:\s]/)[0];
  const arg = tag.slice(head.length).replace(/^[:\s]+/, '').trim();
  switch (head) {
    case 'cron': {
      const parts = arg.split(/\s+/).filter(Boolean);
      const last = parts[parts.length - 1] ?? '';
      const hasTz = last.includes('/');
      const schedule = (hasTz ? parts.slice(0, -1) : parts).join(' ') || '0 9 * * 1-5';
      return { type: 'cron', schedule, timezone: hasTz ? last : undefined };
    }
    case 'before-meeting': {
      const minutes = parseInt(arg, 10);
      return { type: 'before_meeting', minutesBefore: isNaN(minutes) ? 30 : minutes };
    }
    case 'field-change': {
      const arrowIdx = arg.indexOf('→');
      if (arrowIdx === -1) return { type: 'conversation_change' };
      const field = arg.slice(0, arrowIdx).trim();
      const toRaw = arg.slice(arrowIdx + 1).trim();
      const toValue = toRaw === '*' || toRaw === '' ? undefined : toRaw;
      return { type: 'conversation_change', watchFields: [{ field, toValue }] };
    }
    case 'any':
    case '':
      return { type: 'event_occurred' };
    default: {
      const event = TAG_TO_EVENT[head];
      return event ? { type: 'event_occurred', eventTypes: [event] } : { type: 'event_occurred' };
    }
  }
}

/**
 * Serialize a {@link TriggerConfig} back to an `[on:<tag>]` tag, matching the
 * backend `triggerTag` grammar (apps/server/src/services/documents/convention-paths.ts).
 * A single `[on:]` block is one trigger, so when several event types are
 * selected only the first maps onto this block's tag.
 */
function configToTag(config: TriggerConfig): string {
  switch (config.type) {
    case 'cron':
      return `cron: ${config.schedule}${config.timezone ? ` ${config.timezone}` : ''}`;
    case 'before_meeting':
      return `before-meeting: ${config.minutesBefore}`;
    case 'conversation_change': {
      const watch = config.watchFields?.[0];
      if (!watch?.field) return 'any';
      return `field-change: ${watch.field} → ${watch.toValue ?? '*'}`;
    }
    case 'event_occurred': {
      const types = config.eventTypes ?? [];
      if (types.length === 0) return 'any';
      return EVENT_TO_TAG[types[0]] ?? types[0];
    }
    case 'webhook':
      return 'webhook';
  }
}

function TriggerView({ node, updateAttributes, extension }: NodeViewProps) {
  const [open, setOpen] = useState(false);
  const { aopId, ownerUserId } = extension.options as PlaybookTriggerOptions;
  const { conversationFieldDefs, customFieldDefs } = usePlaybookAop(aopId, ownerUserId);
  const tag = String(node.attrs.trigger ?? 'any');
  const style = triggerStyle(tag);
  // The editor doesn't manage the `if:` condition clause on event tags — capture
  // it so it rides through an edit instead of being dropped (see tagToConfig).
  const ifIdx = tag.indexOf('if:');
  const ifClause = ifIdx >= 0 ? tag.slice(ifIdx).trim() : '';

  return (
    <NodeViewWrapper
      as="div"
      data-playbook-trigger=""
      className="border-border/70 bg-muted/30 my-2 rounded-xl border"
    >
      <div className="flex items-center gap-2 px-3 pt-2.5" contentEditable={false}>
        <Popover open={open} onOpenChange={setOpen}>
          <PopoverTrigger asChild>
            <button
              type="button"
              className="group inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold transition-shadow hover:shadow-sm"
              style={{
                color: style.color,
                background: `${style.color}1a`,
                border: `1px solid ${style.color}33`,
              }}
            >
              {style.label}
              <Pencil className="h-2.5 w-2.5 opacity-0 transition-opacity group-hover:opacity-70" />
            </button>
          </PopoverTrigger>
          <PopoverContent align="start" className="w-[360px] p-3">
            <TriggerSection
              config={tagToConfig(tag)}
              onChange={(next) => {
                // Re-append a preserved `if:` clause only while this stays an
                // event trigger — cron / before-meeting / field-change tags
                // don't carry one.
                const serialized = configToTag(next);
                const trigger =
                  ifClause && next.type === 'event_occurred'
                    ? `${serialized} ${ifClause}`
                    : serialized;
                updateAttributes({ trigger });
              }}
              hasTrigger
              onTriggerSet={() => {}}
              conversationFieldDefs={conversationFieldDefs}
              customFieldDefs={customFieldDefs}
              // Webhook is wired only for the XML playbook path, not legacy [on:] tags.
              allowedTriggers={['default', 'event_occurred', 'cron', 'before_meeting', 'conversation_change']}
            />
          </PopoverContent>
        </Popover>
      </div>
      <NodeViewContent className="px-3 pb-2.5 pt-1.5 text-sm" />
    </NodeViewWrapper>
  );
}

/**
 * Slice the inline content of `block` between two `textContent` offsets,
 * preserving inline atom nodes (e.g. a `fileLink` that fileLinkAutoConvert
 * already produced). Atoms contribute nothing to `textContent`, so a plain
 * string slice of `block.textContent` would silently drop them. Whitespace
 * left by the `[on:…]` / `[/on]` markers is trimmed off the region edges.
 */
function sliceInlineBody(block: PMNode, from: number, to: number, schema: Schema): PMNode[] {
  const out: PMNode[] = [];
  let offset = 0; // running position in `block.textContent` space
  block.forEach((child) => {
    if (child.isText) {
      const text = child.text ?? '';
      const start = offset;
      const end = start + text.length;
      const s = Math.max(from, start);
      const e = Math.min(to, end);
      if (s < e) {
        const sub = text.slice(s - start, e - start);
        if (sub) out.push(schema.text(sub, child.marks));
      }
      offset = end;
    } else if (offset >= from && offset <= to) {
      // Inline atom: invisible to textContent, so it sits at a single `offset`
      // boundary — keep it when that boundary falls inside the region.
      out.push(child);
    }
  });
  if (out.length > 0 && out[0].isText) {
    const trimmed = (out[0].text ?? '').replace(/^\s+/, '');
    if (trimmed) out[0] = schema.text(trimmed, out[0].marks);
    else out.shift();
  }
  if (out.length > 0 && out[out.length - 1].isText) {
    const i = out.length - 1;
    const trimmed = (out[i].text ?? '').replace(/\s+$/, '');
    if (trimmed) out[i] = schema.text(trimmed, out[i].marks);
    else out.pop();
  }
  return out;
}

export const PlaybookTriggerNode = Node.create<PlaybookTriggerOptions>({
  name: 'playbookTrigger',
  group: 'block',
  content: 'block+',
  defining: true,

  addOptions() {
    return { aopId: null, ownerUserId: null };
  },

  addAttributes() {
    return {
      trigger: {
        default: 'any',
        parseHTML: (el) => el.getAttribute('data-trigger') ?? 'any',
        renderHTML: (attrs) => (attrs.trigger ? { 'data-trigger': attrs.trigger } : {}),
      },
    };
  },

  parseHTML() {
    return [{ tag: 'div[data-playbook-trigger]' }];
  },

  renderHTML({ HTMLAttributes }) {
    return ['div', mergeAttributes({ 'data-playbook-trigger': '' }, HTMLAttributes), 0];
  },

  addNodeView() {
    return ReactNodeViewRenderer(TriggerView);
  },

  // Block tokenizer — used when content is parsed as markdown (e.g. paste / fresh
  // setContent). Matches `[on:<tag>]` … `[/on]` and parses the body as blocks.
  markdownTokenizer: {
    name: 'playbookTrigger',
    level: 'block',
    start(src: string) {
      const idx = src.search(/^\s*\[on:[^\]]+\]/m);
      return idx >= 0 ? idx : -1;
    },
    tokenize(
      src: string,
      _tokens: MarkdownToken[],
      lexer: MarkdownLexerConfiguration,
    ): MarkdownToken | undefined {
      const match = src.match(/^\s*\[on:([^\]]+)\][ \t]*\r?\n?([\s\S]*?)\r?\n?[ \t]*\[\/on\][ \t]*\r?\n?/);
      if (!match) return undefined;
      const [raw, tag, body] = match;
      return {
        type: 'playbookTrigger',
        raw,
        trigger: tag.trim(),
        tokens: lexer.blockTokens(body.trim()),
      } as MarkdownToken & { trigger: string };
    },
  },

  parseMarkdown(token=[redacted] & { trigger?: string }, helpers: MarkdownParseHelpers) {
    const children = helpers.parseChildren(token.tokens ?? []);
    return helpers.createNode(
      'playbookTrigger',
      { trigger: token.trigger ?? 'any' },
      children.length > 0 ? children : [helpers.createNode('paragraph', {}, [])],
    );
  },

  renderMarkdown(
    node: { attrs?: Record<string, unknown>; content?: unknown[] },
    helpers: MarkdownRendererHelpers,
  ) {
    const tag = String(node.attrs?.trigger ?? 'any');
    const body = helpers
      .renderChildren((node.content ?? []) as Parameters<typeof helpers.renderChildren>[0])
      .trim();
    return `[on:${tag}]\n${body}\n[/on]`;
  },

  // Wrap plain-text `[on:…]` … `[/on]` regions (from Y.js / server reconstruction)
  // into the node so loaded content renders as a callout. Idempotent: once wrapped,
  // the markers live in the node's attrs, not as top-level text. Runs as a
  // deferred dispatch (not appendTransaction) so the conversion lands in the
  // Y.Doc — see deferred-auto-convert.ts for why.
  addProseMirrorPlugins() {
    const triggerType = this.type;
    return [
      createDeferredAutoConvertPlugin('playbookTriggerAutoConvert', (state) => {
        const paragraph = state.schema.nodes.paragraph;
        if (!paragraph) return null;

        const blocks: { node: PMNode; start: number; end: number; text: string }[] = [];
        state.doc.forEach((node, offset) => {
          blocks.push({ node, start: offset, end: offset + node.nodeSize, text: node.textContent });
        });

        for (let i = 0; i < blocks.length; i += 1) {
          const b = blocks[i];
          if (!/^\s*\[on:([^\]]+)\]/.test(b.text)) continue;
          const tag = (b.text.match(/^\s*\[on:([^\]]+)\]/) as RegExpMatchArray)[1].trim();

          const openMarkerEnd = b.text.indexOf(']', b.text.indexOf('[on:')) + 1;
          const sameLineClose = b.text.indexOf('[/on]', openMarkerEnd) >= 0;

          let regionEnd = -1;
          const bodyNodes: PMNode[] = [];
          if (sameLineClose) {
            regionEnd = b.end; // whole trigger on one line
            // Preserve any inline body between the markers, e.g.
            // `[on:meeting] @subagents/deal-notifier-agent [/on]`. Slice the
            // node's inline content (not b.text) so fileLink atoms already
            // converted by fileLinkAutoConvert survive — textContent omits them.
            const closeAt = b.text.indexOf('[/on]', openMarkerEnd);
            const inner = sliceInlineBody(b.node, openMarkerEnd, closeAt, state.schema);
            if (inner.length > 0) bodyNodes.push(paragraph.create(null, inner));
          } else {
            let closeIdx = -1;
            for (let j = i + 1; j < blocks.length; j += 1) {
              if (/\[\/on\]/.test(blocks[j].text)) {
                closeIdx = j;
                break;
              }
              if (/^\s*\[on:[^\]]+\]/.test(blocks[j].text)) break; // next opener first
            }
            if (closeIdx === -1) continue; // unterminated → leave as text
            regionEnd = blocks[closeIdx].end;
            // The closing block is consumed whole, so any text sharing its
            // line with `[/on]` (e.g. `[/on] Note: see companion doc`) is
            // dropped. Warn rather than lose it silently.
            if (blocks[closeIdx].text.trim() !== '[/on]') {
              console.warn(
                '[playbookTriggerAutoConvert] dropping content on the [/on] line:',
                blocks[closeIdx].text.trim(),
              );
            }
            for (let k = i + 1; k < closeIdx; k += 1) bodyNodes.push(blocks[k].node);
          }

          const content = bodyNodes.length > 0 ? Fragment.fromArray(bodyNodes) : Fragment.from(paragraph.create());
          const triggerNode = triggerType.create({ trigger: tag }, content);
          const tr = state.tr.replaceWith(b.start, regionEnd, triggerNode);
          // One region per pass; re-fires for the rest until nothing remains.
          return tr.docChanged ? tr : null;
        }
        return null;
      }),
    ];
  },
});