IntegrationNode.tsx11.4 KBView on GitHub
'use client';

import { Node, mergeAttributes } from '@tiptap/core';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
import { useEffect, useRef, useState } from 'react';
import { Slack, MessageSquare, Plug, Hash, Search, Sparkles, Mail } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';

/**
 * The inline `#` integration kinds that render as a chip. `trigger` is handled
 * separately by the block-level {@link TriggerNode} because it needs an
 * expandable configuration surface.
 */
export type IntegrationKind = 'slack' | 'imessage' | 'mcp' | 'web_search' | 'enrich' | 'email';

const ICON_BY_KIND: Record<IntegrationKind, React.ComponentType<{ className?: string }>> = {
  slack: Slack,
  imessage: MessageSquare,
  mcp: Plug,
  web_search: Search,
  enrich: Sparkles,
  email: Mail,
};

export const INTEGRATION_LABEL: Record<IntegrationKind, string> = {
  slack: 'Slack',
  imessage: 'iMessage',
  mcp: 'MCP',
  web_search: 'Web search',
  enrich: 'Enrich',
  email: 'Email me',
};

/** Free-text config carried by a Slack chip. */
export interface SlackIntegrationConfig {
  channel?: string;
}

/**
 * Free-text config carried by an MCP chip.
 *
 * There is deliberately no credential field. A chip's config is serialized into the
 * document body, which the agent reads and can quote — a token stored here would be
 * a plaintext credential inside a document. Auth belongs to the connection, in
 * Settings → Connections, where it is encrypted and never returned to the client.
 */
export interface McpIntegrationConfig {
  name?: string;
  serverUrl?: string;
  instructions?: string;
}

export type IntegrationConfig = SlackIntegrationConfig & McpIntegrationConfig;

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

/** The label shown inside the chip — derived from config when available. */
function chipLabel(
  kind: IntegrationKind,
  config: IntegrationConfig,
  fallbackLabel: string | null,
): string {
  if (kind === 'slack') return config.channel ? `#${config.channel}` : INTEGRATION_LABEL.slack;
  if (kind === 'mcp') return config.name?.trim() || INTEGRATION_LABEL.mcp;
  return fallbackLabel?.trim() || INTEGRATION_LABEL[kind] || 'Integration';
}

const CHIP_CLASS =
  'bg-muted hover:bg-muted/70 text-foreground/90 mx-0.5 inline-flex select-none items-center gap-1 rounded-full px-2 py-0.5 align-baseline text-xs font-medium leading-none transition-colors';

/**
 * Reads the transient `autoOpen` attribute once on mount so a freshly-inserted
 * chip opens its configuration surface, then clears it so the panel doesn't
 * re-open on reload. The attribute never serialises (see `addAttributes`).
 */
function useAutoOpen(node: NodeViewProps['node'], updateAttributes: NodeViewProps['updateAttributes']) {
  const [open, setOpen] = 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]);
  return [open, setOpen] as const;
}

/** Slack chip — click opens a popover to set the channel (free text). */
function SlackIntegration({ node, updateAttributes }: NodeViewProps) {
  const config = parseConfig(node.attrs.config as string | null);
  const [open, setOpen] = useAutoOpen(node, updateAttributes);

  const setChannel = (value: string) => {
    const channel = value.trim().replace(/^#/, '');
    updateAttributes({
      config: JSON.stringify({ ...parseConfig(node.attrs.config as string | null), channel: channel || undefined }),
    });
  };

  return (
    <NodeViewWrapper as="span" data-integration-node="" className="inline" contentEditable={false}>
      <Popover open={open} onOpenChange={setOpen}>
        <PopoverTrigger asChild>
          <button type="button" className={`${CHIP_CLASS} cursor-pointer`}>
            <Slack className="text-muted-foreground h-3 w-3 shrink-0" />
            <span className="truncate">{chipLabel('slack', config, null)}</span>
          </button>
        </PopoverTrigger>
        <PopoverContent align="start" className="w-64 space-y-1.5 p-3">
          <Label htmlFor="slack-channel" className="text-xs">
            Slack channel
          </Label>
          <div className="flex items-center gap-1">
            <span className="text-muted-foreground text-sm">#</span>
            <Input
              id="slack-channel"
              autoFocus
              value={config.channel ?? ''}
              onChange={(e) => setChannel(e.target.value)}
              placeholder="general"
              className="h-7 text-sm"
            />
          </div>
          <p className="text-muted-foreground text-xs">
            The channel this playbook reads from or posts to.
          </p>
        </PopoverContent>
      </Popover>
    </NodeViewWrapper>
  );
}

/** MCP chip — click opens a dialog to configure the server (free text). */
function McpIntegration({ node, updateAttributes }: NodeViewProps) {
  const config = parseConfig(node.attrs.config as string | null);
  const [open, setOpen] = useAutoOpen(node, updateAttributes);
  const [draft, setDraft] = useState<McpIntegrationConfig>(config);

  // Re-seed the draft from the persisted config each time the dialog opens.
  useEffect(() => {
    if (open) setDraft(parseConfig(node.attrs.config as string | null));
  }, [open, node.attrs.config]);

  const save = () => {
    updateAttributes({
      config: JSON.stringify({
        name: draft.name?.trim() || undefined,
        serverUrl: draft.serverUrl?.trim() || undefined,
        instructions: draft.instructions?.trim() || undefined,
      }),
    });
    setOpen(false);
  };

  return (
    <NodeViewWrapper as="span" data-integration-node="" className="inline" contentEditable={false}>
      <button type="button" onClick={() => setOpen(true)} className={`${CHIP_CLASS} cursor-pointer`}>
        <Plug className="text-muted-foreground h-3 w-3 shrink-0" />
        <span className="truncate">{chipLabel('mcp', config, null)}</span>
      </button>
      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent className="sm:max-w-md">
          <DialogHeader>
            <DialogTitle>Configure MCP server</DialogTitle>
            <DialogDescription>
              Connect an MCP server this playbook can call as a tool source.
            </DialogDescription>
          </DialogHeader>
          <div className="space-y-3">
            <div className="space-y-1.5">
              <Label htmlFor="mcp-name" className="text-xs">
                Name
              </Label>
              <Input
                id="mcp-name"
                value={draft.name ?? ''}
                onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
                placeholder="Mintlify Docs"
                className="h-8 text-sm"
              />
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="mcp-url" className="text-xs">
                Server URL
              </Label>
              <Input
                id="mcp-url"
                value={draft.serverUrl ?? ''}
                onChange={(e) => setDraft((d) => ({ ...d, serverUrl: e.target.value }))}
                placeholder="https://example.com/mcp"
                className="h-8 text-sm"
              />
            </div>
            <p className="text-muted-foreground text-xs">
              Credentials are not set here. Add the server&apos;s token — and choose which of
              its tools the agent may call — in Settings → Connections.
            </p>
            <div className="space-y-1.5">
              <Label htmlFor="mcp-instructions" className="text-xs">
                Instructions
                <span className="text-muted-foreground font-normal"> (optional)</span>
              </Label>
              <Textarea
                id="mcp-instructions"
                value={draft.instructions ?? ''}
                onChange={(e) => setDraft((d) => ({ ...d, instructions: e.target.value }))}
                placeholder="When and how the agent should use this server."
                className="min-h-16 text-sm"
              />
            </div>
          </div>
          <DialogFooter>
            <Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
              Cancel
            </Button>
            <Button size="sm" onClick={save}>
              Save
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </NodeViewWrapper>
  );
}

/**
 * Inline atom chip representing an integration reference inserted via the `#`
 * mention menu (or the `/` menu) in a playbook document. Stores the `kind` plus
 * a `config` snapshot (free-text channel for Slack, server details for MCP).
 * iMessage is presentational. Editor + persistence only — no backend wiring yet.
 */
function IntegrationNodeView(props: NodeViewProps) {
  const kind = (props.node.attrs.kind as IntegrationKind | null) ?? 'slack';

  if (kind === 'slack') return <SlackIntegration {...props} />;
  if (kind === 'mcp') return <McpIntegration {...props} />;

  const label = chipLabel(kind, {}, props.node.attrs.label as string | null);
  const Icon = ICON_BY_KIND[kind] ?? Hash;
  return (
    <NodeViewWrapper
      as="span"
      data-integration-node=""
      className={CHIP_CLASS}
      contentEditable={false}
    >
      <Icon className="text-muted-foreground h-3 w-3 shrink-0" />
      <span className="truncate">{label}</span>
    </NodeViewWrapper>
  );
}

export const IntegrationNode = Node.create({
  name: 'integrationNode',
  group: 'inline',
  inline: true,
  atom: true,
  selectable: true,
  draggable: false,

  addAttributes() {
    return {
      kind: {
        default: 'slack' as IntegrationKind,
        parseHTML: (element) => element.getAttribute('data-integration-kind'),
        renderHTML: (attributes) =>
          attributes.kind ? { 'data-integration-kind': attributes.kind } : {},
      },
      label: {
        default: null as string | null,
        parseHTML: (element) => element.getAttribute('data-integration-label'),
        renderHTML: (attributes) =>
          attributes.label ? { 'data-integration-label': attributes.label } : {},
      },
      config: {
        default: null as string | null,
        parseHTML: (element) => element.getAttribute('data-integration-config'),
        renderHTML: (attributes) =>
          attributes.config ? { 'data-integration-config': attributes.config } : {},
      },
      // Transient — opens the config surface for a freshly-inserted chip. Never
      // parsed or rendered, so it doesn't survive a reload.
      autoOpen: {
        default: false,
        parseHTML: () => false,
        renderHTML: () => ({}),
      },
    };
  },

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

  renderHTML({ HTMLAttributes }) {
    return ['span', mergeAttributes({ 'data-integration-node': '' }, HTMLAttributes)];
  },

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