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';