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