TriggerNode.tsx13.1 KBView on GitHub
'use client';

import { Node, mergeAttributes } from '@tiptap/core';
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
import { useEffect, useRef, useState } from 'react';
import {
  Zap,
  Mail,
  Calendar,
  MessageSquare,
  Clock,
  GitBranch,
  HelpCircle,
  ChevronDown,
  Webhook,
  type LucideIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
  TriggerSection,
  type TriggerConfig,
  type CrmEventType,
} from '@/modules/aop/components/TriggerConfigEditor';
import { cronLabel } from '@/modules/aop/components/cron';
import {
  TRIGGER_DISPATCH_EXPLAINER,
  TRIGGER_EVENT_TYPES,
  TRIGGER_TYPE_EXPLAINER,
  triggerDispatchFor,
  type PlaybookTriggerDispatch,
} from '@/modules/agents/utils/trigger-events';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { usePlaybookAop } from './usePlaybookAop';
import { WebhookTriggerPanel } from './WebhookTriggerPanel';

/** Options for {@link TriggerNode} — `aopId` sources the field-change pickers. */
export interface TriggerNodeOptions {
  aopId: string | null;
  /** The AOP's owner, so a teammate's document resolves against them. */
  ownerUserId: string | null;
}

/** Default config for a freshly-inserted `#trigger` block — fires on any event. */
export const DEFAULT_TRIGGER_CONFIG: TriggerConfig = { type: 'event_occurred' };

function parseConfig(raw: string | null): TriggerConfig {
  if (!raw) return DEFAULT_TRIGGER_CONFIG;
  try {
    return JSON.parse(raw) as TriggerConfig;
  } catch {
    return DEFAULT_TRIGGER_CONFIG;
  }
}

const EVENT_META: Record<CrmEventType, { tag: string; icon: LucideIcon }> = {
  email: { tag: 'email', icon: Mail },
  meeting: { tag: 'meeting', icon: Calendar },
  slack: { tag: 'slack', icon: MessageSquare },
  // `external_crm`, NOT `crm-sync`: the server's trigger-types.ts header names
  // `crm-sync` as a type that has never existed — a `<trigger type="crm-sync">`
  // compiles, lands in `eventBlocks`, and is never looked up, "the exact silent
  // no-op the docs warn authors about". The serializer has always emitted the real
  // name; only this badge said otherwise, and the badge is what an author copies.
  // `Zap`, the same generic mark `any` wears. Not a building and not sync arrows: the
  // first says "a company" and invites the question "which one?", to which there is no
  // answer, and the second says "refresh", which is an action rather than an event. See
  // TriggerEventType.Icon — this event has no honest mark, so it wears the generic one.
  external_crm: { tag: 'external_crm', icon: Zap },
  call: { tag: 'call', icon: Zap },
  note: { tag: 'note', icon: Zap },
};

/**
 * Maps a TriggerConfig to the `on:` badge shown in the callout header, mirroring
 * the playbook's `[on:trigger]` tags (see apps/mail/docs/playbook-doc.md).
 */
function triggerBadge(config: TriggerConfig): { label: string; Icon: LucideIcon } {
  switch (config.type) {
    case 'event_occurred': {
      const types = config.eventTypes ?? [];
      if (types.length === 0) return { label: 'on: any', Icon: Zap };
      const first = EVENT_META[types[0]];
      const label = types.map((t) => EVENT_META[t]?.tag ?? t).join(', ');
      return { label: `on: ${label}`, Icon: first?.icon ?? Zap };
    }
    case 'before_meeting':
      return { label: `on: before-meeting · ${config.minutesBefore}min`, Icon: Clock };
    case 'cron':
      return { label: `on: cron · ${cronLabel(config.schedule)}`, Icon: Clock };
    case 'conversation_change': {
      const wf = config.watchFields?.[0];
      const suffix = wf ? `: ${wf.field}${wf.toValue ? ` → ${wf.toValue}` : ''}` : '';
      return { label: `on: field-change${suffix}`, Icon: GitBranch };
    }
    case 'webhook':
      return { label: 'on: webhook', Icon: Webhook };
  }
}

/**
 * The `<trigger type="…">` literal this config serializes to.
 *
 * A deliberate mirror of `serializeTriggerNode`
 * (apps/server/src/services/document-saving/serialize-playbook-xml.ts), because the
 * editor's `TriggerConfig` vocabulary and the XML one are NOT the same words:
 * `conversation_change` is written `field-change`, `before_meeting` is
 * `before-meeting`, and an `event_occurred` block with no event types is `any`.
 * Reading the dispatch off the editor's own type names would answer for a tag the
 * document never contains.
 *
 * An `event_occurred` block with several event types serializes to one `<trigger>`
 * per type. They are all plain event names, so they share a dispatch and the first
 * one answers for the block.
 */
export function playbookTriggerTypeFor(config: TriggerConfig): string {
  switch (config.type) {
    case 'event_occurred':
      return config.eventTypes?.[0] ?? 'any';
    case 'before_meeting':
      return 'before-meeting';
    case 'cron':
      return 'cron';
    case 'conversation_change':
      return 'field-change';
    case 'webhook':
      return 'webhook';
  }
}

/** The dispatch mechanism a configured trigger block fires. */
export function triggerDispatchForConfig(config: TriggerConfig): PlaybookTriggerDispatch {
  return triggerDispatchFor(playbookTriggerTypeFor(config));
}

/**
 * The `?` beside the pill: what this kind of trigger fires on, and what happens when it does.
 *
 * Two facts, in the order they are needed. WHAT WAKES IT is the trigger's own semantics —
 * and for `any` that is a promise with no visible referent ("every event" never says what
 * everything is), so it is answered with the four event glyphs, the same list and the same
 * marks the agent's Sources tab draws. WHAT HAPPENS NEXT is the dispatch mechanism, which
 * decides whether the prose in this block is read by something that CHOOSES or handed
 * wholesale to every agent below — the same words either way, opposite meanings.
 */
function TriggerExplainer({
  config,
  dispatch,
}: {
  config: TriggerConfig;
  dispatch: PlaybookTriggerDispatch;
}) {
  const playbookType = playbookTriggerTypeFor(config);
  const explainer = TRIGGER_TYPE_EXPLAINER[playbookType];
  const isEveryEvent = playbookType === 'any';

  return (
    <Popover>
      <PopoverTrigger asChild>
        <button
          type="button"
          aria-label="What does this trigger do?"
          className="text-muted-foreground/70 hover:text-foreground hover:bg-foreground/[0.06] inline-flex size-5 cursor-pointer items-center justify-center rounded-full transition-colors"
        >
          <HelpCircle className="size-3.5" />
        </button>
      </PopoverTrigger>
      {/* Narrow on purpose: this is two short paragraphs, and a wide popover invites a
          third. `contentEditable={false}` because the whole header row is. */}
      <PopoverContent
        align="start"
        className="w-[19rem] p-3 text-xs leading-relaxed"
        contentEditable={false}
      >
        <p className="text-foreground font-medium">What wakes it</p>
        <p className="text-muted-foreground mt-1">{explainer ?? `A ${playbookType} event.`}</p>
        {isEveryEvent ? (
          <div
            className="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-3 gap-y-1"
            data-trigger-event-glyphs=""
          >
            {TRIGGER_EVENT_TYPES.map(({ value, short, Icon, tint }) => (
              <span key=[redacted] className="inline-flex items-center gap-1">
                {/* `external_crm` has no glyph — see TriggerEventType.Icon. Here it is a
                    named row in prose, so the name alone carries it. */}
                {Icon ? <Icon className={cn('size-3.5 shrink-0', tint)} aria-hidden="true" /> : null}
                {short}
              </span>
            ))}
          </div>
        ) : null}

        <p className="text-foreground mt-3 font-medium">What happens then</p>
        <p className="text-muted-foreground mt-1" data-trigger-dispatch={dispatch}>
          {TRIGGER_DISPATCH_EXPLAINER[dispatch]}
        </p>
      </PopoverContent>
    </Popover>
  );
}

/**
 * Block-level "callout" inserted via `#trigger` in a Playbook document.
 * The header shows the resolved `on:` badge and toggles an inline editor that
 * reuses {@link TriggerSection} (select type → select details, as in agent
 * configuration). The block's prose body (plain English + `@` references) lives
 * in an editable {@link NodeViewContent} hole.
 *
 * Field-backed trigger panels (conversation_change, field filters) read the
 * backing AOP's field definitions via {@link usePlaybookAop}, keyed by the
 * `aopId` this node is configured with.
 */
function TriggerNodeView({ node, updateAttributes, extension }: NodeViewProps) {
  const { aopId, ownerUserId } = extension.options as TriggerNodeOptions;
  const { conversationFieldDefs, customFieldDefs } = usePlaybookAop(aopId, ownerUserId);

  // A freshly-inserted trigger opens its config editor by default; the transient
  // `autoOpen` attribute is cleared on mount so it doesn't re-open on reload.
  const [editing, setEditing] = useState<boolean>(() => Boolean(node.attrs.autoOpen));
  const cleared = useRef(false);
  useEffect(() => {
    if (node.attrs.autoOpen && !cleared.current) {
      cleared.current = true;
      updateAttributes({ autoOpen: false });
    }
  }, [node.attrs.autoOpen, updateAttributes]);

  const config = parseConfig(node.attrs.config as string | null);
  const { label, Icon } = triggerBadge(config);
  const dispatch = triggerDispatchForConfig(config);

  const onChange = (next: TriggerConfig) => {
    updateAttributes({ config: JSON.stringify(next) });
  };

  return (
    <NodeViewWrapper
      as="div"
      data-trigger-node=""
      className="border-border bg-muted/30 my-2 rounded-xl border"
    >
      <div className="px-3 pt-2.5" contentEditable={false} suppressContentEditableWarning>
        <div className="flex items-center gap-2">
          <button
            type="button"
            onClick={() => setEditing((v) => !v)}
            className="bg-primary/10 text-primary hover:bg-primary/15 inline-flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold transition-colors"
          >
            <Icon className="h-3 w-3" />
            {label}
            <ChevronDown className={cn('h-3 w-3 transition-transform', editing && 'rotate-180')} />
          </button>
          {/*
            What actually fires. The pill says WHEN; this says by WHICH MECHANISM — the
            single most misread thing about a trigger block, because the two dispatch paths
            treat the prose below in opposite ways and neither announces itself.

            It sits in a `?` popover rather than as a line under the pill because it is
            REFERENCE, not status: it is identical for every trigger of the same kind, it
            never changes, and a sentence that never changes on a row you read every day
            stops being read within a week while still costing a line of height on every
            block in the document. Behind a `?` it is one click away on the day you need it
            and invisible on the days you do not.
          */}
          <TriggerExplainer config={config} dispatch={dispatch} />
        </div>
      </div>

      {editing && (
        <div
          className="mx-3 mt-2 pt-1"
          contentEditable={false}
          suppressContentEditableWarning
        >
          <TriggerSection
            config={config}
            onChange={onChange}
            hasTrigger
            onTriggerSet={() => {}}
            conversationFieldDefs={conversationFieldDefs}
            customFieldDefs={customFieldDefs}
          />
          {config.type === 'webhook' && (
            <div className="mt-3">
              <WebhookTriggerPanel
                aopId={aopId}
                ownerUserId={ownerUserId}
                webhookId={config.webhookId}
                onMinted={(webhookId) => onChange({ ...config, webhookId })}
              />
            </div>
          )}
          {/* Separates the trigger configuration from the instructions body. */}
          <div className="border-border/60 mt-4 border-t" />
        </div>
      )}

      <NodeViewContent className="px-3 pb-2 pt-1.5 text-sm" />
    </NodeViewWrapper>
  );
}

export const TriggerNode = Node.create<TriggerNodeOptions>({
  name: 'triggerNode',
  group: 'block',
  content: 'block+',
  draggable: false,
  selectable: true,
  defining: true,

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

  addAttributes() {
    return {
      config: {
        default: JSON.stringify(DEFAULT_TRIGGER_CONFIG),
        parseHTML: (element) => element.getAttribute('data-trigger-config'),
        renderHTML: (attributes) =>
          attributes.config ? { 'data-trigger-config': attributes.config } : {},
      },
      // Transient — opens the config editor for a freshly-inserted block. Never
      // parsed or rendered, so it doesn't survive a reload.
      autoOpen: {
        default: false,
        parseHTML: () => false,
        renderHTML: () => ({}),
      },
    };
  },

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

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

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