HashMentionList.tsx6.5 KBView on GitHub 'use client';
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import {
Slack,
MessageSquare,
Zap,
Plug,
Bot,
Search,
Sparkles,
Globe,
Mail,
KanbanSquare,
Table2,
FileText,
type LucideIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Every kind the `#` menu can insert. `trigger` becomes a block callout, the
* integration kinds become inline chips, and the four DOCUMENT kinds (`subagent`,
* `board`, `table`, `doc`) create a document and drop a `<ref>` to it.
*
* The document kinds are here rather than only under `@` because `@` REFERENCES what
* already exists — it cannot make the thing. Until now the only document a playbook
* could create inline was a subagent, so an agent that needed a board beside it meant
* leaving the playbook, finding the Files tree, making it, coming back, and remembering
* what you called it.
*/
export type HashMentionKind =
| 'slack'
| 'imessage'
| 'trigger'
| 'mcp'
| 'post_api'
| 'subagent'
| 'board'
| 'table'
| 'doc'
| 'web_search'
| 'enrich'
| 'email';
/**
* The kinds that create a DOCUMENT, as opposed to inserting a node.
*
* `subagent` keeps its own creation flow — it is the one whose ref must land inside a
* `<trigger>`, because a subagent ref outside one is an orphan that shows in the roster
* and never fires. A board or a table is the opposite case: it is a resource, and a ref
* to it inside a trigger would be a thing the playbook tries to RUN.
*/
export const PLAYBOOK_DOCUMENT_KINDS = ['board', 'table', 'doc'] as const;
export type PlaybookDocumentKind = (typeof PLAYBOOK_DOCUMENT_KINDS)[number];
export function isPlaybookDocumentKind(kind: HashMentionKind): kind is PlaybookDocumentKind {
return (PLAYBOOK_DOCUMENT_KINDS as readonly string[]).includes(kind);
}
export interface HashMentionItem {
kind: HashMentionKind;
}
interface ConfigOptionMeta {
kind: HashMentionKind;
label: string;
description: string;
icon: LucideIcon;
}
/** Canonical `#` menu options, in display order: Trigger, Slack, iMessage, MCP, Post API, … */
export const HASH_MENTION_OPTIONS: ConfigOptionMeta[] = [
{
kind: 'trigger',
label: 'Trigger',
description: 'Configure an agent trigger',
icon: Zap,
},
{ kind: 'slack', label: 'Slack', description: 'Reference a Slack channel', icon: Slack },
{
kind: 'imessage',
label: 'iMessage',
description: 'Reference an iMessage thread',
icon: MessageSquare,
},
{ kind: 'mcp', label: 'MCP', description: 'Configure an MCP server', icon: Plug },
{ kind: 'post_api', label: 'Post API', description: 'POST to a configured endpoint', icon: Globe },
{ kind: 'web_search', label: 'Web search', description: 'Search the web', icon: Search },
{ kind: 'enrich', label: 'Enrich', description: 'Enrich a person or company', icon: Sparkles },
{ kind: 'email', label: 'Email me', description: 'Email the user a message', icon: Mail },
{
kind: 'subagent',
label: 'Subagent',
description: 'Create a new subagent',
icon: Bot,
},
{
kind: 'board',
label: 'Board',
description: 'Create a board this agent works',
icon: KanbanSquare,
},
{
kind: 'table',
label: 'Table',
description: 'Create a table this agent fills',
icon: Table2,
},
{
kind: 'doc',
label: 'Document',
description: 'Create a resource document',
icon: FileText,
},
];
const META_BY_KIND = Object.fromEntries(
HASH_MENTION_OPTIONS.map((o) => [o.kind, o]),
) as Record<HashMentionKind, ConfigOptionMeta>;
interface HashMentionListProps {
items: HashMentionItem[];
command: (item: HashMentionItem) => void;
}
export interface HashMentionListRef {
onKeyDown: (data: { event: KeyboardEvent }) => boolean;
}
/**
* Suggestion popup body for the Config document `#` integration mention.
* Renders one row per matching integration with an icon + short description.
*/
export const HashMentionList = forwardRef<HashMentionListRef, HashMentionListProps>(
({ items, command }, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
useEffect(() => {
itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
setSelectedIndex((i) => (i - 1 + Math.max(items.length, 1)) % Math.max(items.length, 1));
return true;
}
if (event.key === 'ArrowDown') {
setSelectedIndex((i) => (i + 1) % Math.max(items.length, 1));
return true;
}
if (event.key === 'Enter') {
const item = items[selectedIndex];
if (item) {
command(item);
return true;
}
return false;
}
return false;
},
}));
if (items.length === 0) {
return (
<div className="text-muted-foreground z-99999 w-64 rounded-xl border border-[#E7E7E7] bg-white p-2 text-xs shadow-lg dark:border-[#252525] dark:bg-[#1A1A1A]">
No integrations found
</div>
);
}
return (
<div className="z-99999 max-h-64 w-64 overflow-y-auto rounded-xl border border-[#E7E7E7] bg-white p-1 shadow-lg dark:border-[#252525] dark:bg-[#1A1A1A]">
{items.map((item, index) => {
const meta = META_BY_KIND[item.kind];
const Icon = meta.icon;
return (
<button
key=[redacted]
ref={(el) => {
itemRefs.current[index] = el;
}}
onMouseDown={(e) => e.preventDefault()}
onClick={() => command(item)}
className={cn(
'flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors',
index === selectedIndex
? 'bg-gray-100 dark:bg-[#252525]'
: 'hover:bg-gray-50 dark:hover:bg-[#1E1E1E]',
)}
>
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm">{meta.label}</span>
<span className="text-muted-foreground truncate text-xs">{meta.description}</span>
</span>
</button>
);
})}
</div>
);
},
);
HashMentionList.displayName = 'HashMentionList';