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