Skip to content
LogoLogo

Chat Input

Loading...

Loading...

A specialized input component designed for AI chat interfaces that combines a textarea with a send button, loading states, and expandable interactions. It is published as @camp/chat-input and exported as ChatInput (formerly @camp/ai-chat-input / AiChatInput). The component supports two distinct appearances: a standard default mode and an expandable mode that collapses when empty and features animated rotating placeholders.

Resources

Loading...

Loading...

Loading...

Loading...

Install

yarn add @camp/chat-input

Props

PropTypeDefaultDescription
onChange *(e: ChangeEvent<HTMLTextAreaElement>) => voidthe function to call when the value changes
onSendMessage *(e: FormEvent<HTMLFormElement>) => voidthe function to call when the form is submitted
value *stringthe controlled value of the textarea
placeholderstring | string[]the text to display as the placeholder - can be a string or an array of strings if it's an array, the placeholder will cycle through the strings at the specified interval (`placeholderInterval` prop) if it's a string, the placeholder will not animate. If undefined or empty, no placeholder will be shown. the text to display as the placeholder If undefined or empty, no placeholder will be shown.
refForwardedRef<ChatInputHandle>
appearance"default" | "expandable" | "compact"the appearance of the input When set to 'expandable', the component receives a gradient border when expanded. the appearance of the input
disabledbooleanwhether the input is disabled
promptInputIdstringThe id of the prompt input
renderActions() => ReactNodethe actions to render in the button group
data-testidstringThe data-testid for the contenteditable element
isExpandedbooleanexternally controlled expanded state
onExpandedChange(isExpanded: boolean) => voidcallback to control the expanded state externally
showThinkerbooleanwhether to show the Thinker animation
themedbooleanWhether the component should include light/dark theme modes.
renderAttachments() => ReactNodeRender function for attachments section at the top of the input
sendButtonDataTestIdstringThe data-testid of the send button
aria-labelstringDefines a string value that labels the current element. @see aria-labelledby.
getTypeaheadValueString(value: T) => stringFunction to convert the typed value to a string for display/search Required when using complex objects as values
hideSendButtonbooleanwhether to hide the send button
isLoadingbooleanwhether the input is loading
labelstringthe label to display above the input
onTypeaheadSearch(params: TypeaheadSearchParams) => voidCallback for server-side search. Called when a category is expanded OR when the user types after @ (debounced). @example // Category click: { query: '', categoryId: 'em_list' } // User typing: { query: 'sub', categoryId: undefined }
onUrlPaste(url: string) => voidCallback when a valid URL is pasted into the editor. If provided, URLs pasted into the editor will be intercepted and passed to this callback instead of being inserted as text. @param url - The normalized URL that was pasted
serializeTypeaheadValue(value: T) => stringFunction to serialize the typed value for storage in ChipNode Defaults to JSON.stringify for objects
typeaheadCategoriesTypeaheadCategory[]Categories for server-side typeahead search (shown in @ menu). When provided, enables server-side search mode. @example [ { id: 'em_list', label: 'Lists', icon: <ListIcon /> }, { id: 'em_tag', label: 'Tags', icon: <TagIcon /> }, ]
typeaheadDebounceMsnumberDebounce delay in milliseconds for typing search (default: 300ms). Only applies to typing after @ , not to category expansion clicks.
typeaheadLoadingbooleanWhether server-side search results are currently loading. Shows a loading indicator in the typeahead menu.
typeaheadResultsTypeaheadOptionData<T>[]Results from server-side search to display in the typeahead menu. Update this prop with results from your API response.
data-pendostring`data-pendo` attribute for the contenteditable element
labelVisibility"visible" | "visually-hidden"'visible'Controls whether the label is shown visibly above the input or only to assistive tech. In both modes the label is associated with the editor via aria-labelledby, which is derived from the input id (`promptInputId`, falling back to `ai-prompt-input`), so the accessible name is always computed from the label.
onTypeaheadQueryChange(query: string) => voidCallback when the typeahead query changes
onTypeaheadSelect(option: TypeaheadOptionData<T>) => voidCallback when a typeahead option is selected - provides the original typed value
placeholderIntervalnumberthe duration in milliseconds at which to cycle through the placeholder text
typeaheadMinQueryLengthnumberMinimum number of characters after "@" before showing typeahead search results. Until this many characters are typed, the category menu is shown instead. Default: 2 (e.g. "@" or "@w" show categories; "@we" triggers search).
typeaheadOptionsTypeaheadOptionData<T>[] | TypeaheadGroup<T>[]Options for the typeahead menu (triggered by typing "@" + characters) Can be either: - Flat array: Shows all items immediately, filters as you type - Grouped array: Shows nested menu on "@", flattens for typeahead filtering

Generated from @camp/chat-input@4.17.0 source, ordered by production usage. * required. † standard HTML attribute — all native attributes are supported; only those with production usage are listed.

Variations

Default appearance

The standard chat input appearance provides a consistent, always-visible textarea with a send button.

import { useState } from 'react';
import { ChatInput } from '@camp/chat-input';
 
function ChatComponent() {
  const [message, setMessage] = useState('');
 
  const handleSendMessage = (e) => {
    e.preventDefault();
    // Handle sending message
    console.log('Sending:', message);
    setMessage('');
  };
 
  return (
    <ChatInput
      value={message}
      onChange={(e) => setMessage(e.target.value)}
      onSendMessage={handleSendMessage}
      placeholder="What can I help you with?"
    />
  );
}

Expandable appearance

The expandable appearance creates a compact, single-line input that expands when focused or clicked. This mode features a gradient border when expanded and automatically collapses when empty and unfocused.

import { useState } from 'react';
import { ChatInput } from '@camp/chat-input';
 
function ChatComponent() {
  const [message, setMessage] = useState('');
  const [isExpanded, setIsExpanded] = useState(false);
 
  const handleSendMessage = (e) => {
    e.preventDefault();
    console.log('Sending:', message);
    setMessage('');
  };
 
  return (
    <ChatInput
      appearance="expandable"
      value={message}
      onChange={(e) => setMessage(e.target.value)}
      onSendMessage={handleSendMessage}
      placeholder={[
        'Create a campaign for my new product launch',
        'Generate a blog post about...',
        'Summarize this document...',
        'Write code for...',
        'Explain the concept of...',
      ]}
      placeholderInterval={2000}
      isExpanded={isExpanded}
      onExpandedChange={setIsExpanded}
    />
  );
}

Expandable with rotating placeholder

When using the expandable appearance, you can provide an array of placeholder strings that will automatically rotate at a specified interval. This creates an engaging, dynamic placeholder experience that suggests various use cases to users.

import { useState } from 'react';
import { ChatInput } from '@camp/chat-input';
 
function ChatComponent() {
  const [message, setMessage] = useState('');
  const [isExpanded, setIsExpanded] = useState(false);
 
  const handleSendMessage = (e) => {
    e.preventDefault();
    console.log('Sending:', message);
    setMessage('');
  };
 
  return (
    <ChatInput
      appearance="expandable"
      value={message}
      onChange={(e) => setMessage(e.target.value)}
      onSendMessage={handleSendMessage}
      placeholder={[
        'Create a campaign for my new product launch',
        'Generate a blog post about...',
        'Summarize this document...',
        'Write code for...',
        'Explain the concept of...',
      ]}
      placeholderInterval={2000}
      isExpanded={isExpanded}
      onExpandedChange={setIsExpanded}
    />
  );
}

Expandable with custom placeholder interval

Control the speed of placeholder rotation by adjusting the placeholderInterval prop (in milliseconds).

import { useState } from 'react';
import { ChatInput } from '@camp/chat-input';
 
function ChatComponent() {
  const [message, setMessage] = useState('');
  const [isExpanded, setIsExpanded] = useState(false);
 
  return (
    <ChatInput
      appearance="expandable"
      value={message}
      onChange={(e) => setMessage(e.target.value)}
      onSendMessage={(e) => e.preventDefault()}
      placeholder={[
        'Fast rotation: 1 second',
        'Create a campaign...',
        'Generate content...',
        'Summarize document...',
      ]}
      placeholderInterval={1000}
      isExpanded={isExpanded}
      onExpandedChange={setIsExpanded}
    />
  );
}

Expandable with action buttons

Add custom action buttons that appear when the input expands, such as attachment or formatting controls.

import { useState } from 'react';
import { ChatInput } from '@camp/chat-input';
 
function ChatComponent() {
  const [message, setMessage] = useState('Test message with actions');
  const [isExpanded, setIsExpanded] = useState(true);
 
  const renderActions = () => (
    <>
      <button
        type="button"
        style={{
          background: 'transparent',
          border: '1px solid #ccc',
          borderRadius: '8px',
          padding: '8px 12px',
          cursor: 'pointer',
        }}
      >
        + Add
      </button>
      <button
        type="button"
        style={{
          background: 'transparent',
          border: '1px solid #ccc',
          borderRadius: '8px',
          padding: '8px 12px',
          cursor: 'pointer',
        }}
      >
        🔗 Connect
      </button>
    </>
  );
 
  return (
    <ChatInput
      appearance="expandable"
      value={message}
      onChange={(e) => setMessage(e.target.value)}
      onSendMessage={(e) => e.preventDefault()}
      placeholder={[
        'Ask me anything...',
        'What would you like to know?',
        'How can I help you today?',
      ]}
      renderActions={renderActions}
      isExpanded={isExpanded}
      onExpandedChange={setIsExpanded}
    />
  );
}

Disabled state

Prevent interaction with the input using the disabled prop.

import { useState } from 'react';
import { ChatInput } from '@camp/chat-input';
 
function ChatComponent() {
  const [message, setMessage] = useState('This input is disabled');
 
  return (
    <ChatInput
      value={message}
      onChange={(e) => setMessage(e.target.value)}
      onSendMessage={(e) => e.preventDefault()}
      disabled={true}
    />
  );
}

With accessibility label

Provide an explicit label for screen readers and improved accessibility.

import { useState } from 'react';
import { ChatInput } from '@camp/chat-input';
 
function ChatComponent() {
  const [message, setMessage] = useState('');
 
  const handleSendMessage = (e) => {
    e.preventDefault();
    console.log('Sending:', message);
    setMessage('');
  };
 
  return (
    <ChatInput
      value={message}
      onChange={(e) => setMessage(e.target.value)}
      onSendMessage={handleSendMessage}
      label="Chat message input"
      placeholder="Type your message..."
    />
  );
}

Compact appearance

The compact appearance provides a more condensed layout suitable for sidebars or constrained spaces.

import { useState } from 'react';
import { ChatInput } from '@camp/chat-input';
 
function CompactChat() {
  const [message, setMessage] = useState('');
 
  return (
    <ChatInput
      appearance="compact"
      value={message}
      onChange={(e) => setMessage(e.target.value)}
      onSendMessage={(e) => {
        e.preventDefault();
        console.log('Sending:', message);
        setMessage('');
      }}
      placeholder="Ask me anything..."
    />
  );
}

Production Examples

More states from @camp/chat-input's visual test stories, ranked by production usage. Variations already documented above are not repeated here.

Unthemed Default

import { ChatInput } from '@camp/chat-input';
 
<ChatInput
  value=""
  onChange={() => {}}
  onSendMessage={() => {}}
  themed={false}
/>

Unthemed Loading

import { ChatInput } from '@camp/chat-input';
 
<ChatInput
  value="Message being sent..."
  onChange={() => {}}
  onSendMessage={() => {}}
  isLoading
  themed={false}
/>

Unthemed With Label

import { ChatInput } from '@camp/chat-input';
 
<ChatInput
  value=""
  onChange={() => {}}
  onSendMessage={() => {}}
  label="Unthemed Chat Input"
  themed={false}
/>

Unthemed With Text

import { ChatInput } from '@camp/chat-input';
 
<ChatInput
  value="This is a sample message in unthemed mode"
  onChange={() => {}}
  onSendMessage={() => {}}
  themed={false}
/>

Disabled Default

import { ChatInput } from '@camp/chat-input';
 
<ChatInput value="" onChange={() => {}} onSendMessage={() => {}} disabled />

Disabled With Label

import { ChatInput } from '@camp/chat-input';
 
<ChatInput
  value=""
  onChange={() => {}}
  onSendMessage={() => {}}
  label="Disabled Chat Input"
  disabled
/>

12 more variations in Storybook

Typeahead @ Mentions

The Chat Input includes powerful typeahead functionality triggered by typing @. This enables autocomplete for mentions, context objects, or any custom data.

Basic String Typeahead

Provide a flat array of options for simple mention functionality:

import { useState } from 'react';
import { ChatInput } from '@camp/chat-input';
 
const mentionOptions = [
  { label: 'John Doe', value: 'john.doe' },
  { label: 'Jane Smith', value: 'jane.smith' },
  { label: 'Bob Johnson', value: 'bob.johnson' },
];
 
function ChatWithMentions() {
  const [message, setMessage] = useState('');
 
  return (
    <ChatInput
      value={message}
      onChange={(e) => setMessage(e.target.value)}
      onSendMessage={(e) => {
        e.preventDefault();
        console.log('Message:', message);
        setMessage('');
      }}
      typeaheadOptions={mentionOptions}
      onTypeaheadSelect={(option) => {
        console.log('Selected:', option.value);
      }}
      placeholder="Type @ to mention someone..."
    />
  );
}

Grouped Typeahead with Nested Menu

Use TypeaheadGroup to create categorized options that show as a nested menu when typing @, then flatten for search as you continue typing:

import { useState, useMemo } from 'react';
import { ChatInput, type TypeaheadGroup } from '@camp/chat-input';
import { TagIcon, ListIcon, SegmentIcon } from '@camp/icon';
 
function ChatWithGroupedMentions() {
  const [message, setMessage] = useState('');
 
  const groupedOptions: TypeaheadGroup<string>[] = useMemo(
    () => [
      {
        category: 'Tags',
        icon: <TagIcon />,
        items: [
          { label: 'VIP Customer', value: 'tag:vip', keywords: ['vip', 'customer', 'tag'] },
          { label: 'Newsletter', value: 'tag:newsletter', keywords: ['newsletter', 'tag'] },
        ],
      },
      {
        category: 'Lists',
        icon: <ListIcon />,
        items: [
          { label: 'Active Subscribers', value: 'list:active', keywords: ['active', 'list'] },
          { label: 'Trial Users', value: 'list:trial', keywords: ['trial', 'list'] },
        ],
      },
      {
        category: 'Segments',
        icon: <SegmentIcon />,
        items: [
          {
            label: 'High Value',
            value: 'segment:high-value',
            keywords: ['high', 'value', 'segment'],
          },
          { label: 'At Risk', value: 'segment:at-risk', keywords: ['risk', 'segment'] },
        ],
      },
    ],
    [],
  );
 
  return (
    <ChatInput
      value={message}
      onChange={(e) => setMessage(e.target.value)}
      onSendMessage={(e) => {
        e.preventDefault();
        console.log('Message:', message);
        setMessage('');
      }}
      typeaheadOptions={groupedOptions}
      onTypeaheadSelect={(option) => {
        console.log('Selected:', option.value);
      }}
      placeholder="Type @ to see context options..."
    />
  );
}

How it works:

  1. Type @ → Shows nested menu with categories
  2. Continue typing (@act) → Flattens and filters across all items using keywords
  3. Select option → Inserts as a chip with optional icon

Generic Typeahead with Complex Objects

The typeahead system is fully generic and supports complex typed objects:

import { useState, useMemo, useRef } from 'react';
import { ChatInput, type TypeaheadGroup, type ChatInputHandle } from '@camp/chat-input';
 
interface CRMContact {
  id: number;
  name: string;
  email: string;
  type: 'contact' | 'lead' | 'account';
  metadata: {
    created: string;
    owner: string;
    status: 'active' | 'inactive';
  };
}
 
const contacts: CRMContact[] = [
  {
    id: 1,
    name: 'John Doe',
    email: 'john@example.com',
    type: 'contact',
    metadata: { created: '2024-01-15', owner: 'sales@company.com', status: 'active' },
  },
  // ... more contacts
];
 
function ChatWithTypedMentions() {
  const [message, setMessage] = useState('');
  const [selectedContact, setSelectedContact] = useState<CRMContact | null>(null);
  const inputRef = useRef<ChatInputHandle>(null);
 
  const contactOptions: TypeaheadGroup<CRMContact>[] = useMemo(
    () => [
      {
        category: 'Contacts',
        icon: <ContactIcon />,
        items: contacts.map((contact) => ({
          label: contact.name,
          value: contact, // ✅ Full typed object!
          keywords: [
            contact.name.toLowerCase(),
            contact.email.toLowerCase(),
            contact.type,
            contact.metadata.owner,
          ],
          icon: <ContactIcon />,
        })),
      },
    ],
    [],
  );
 
  return (
    <ChatInput<CRMContact>
      ref={inputRef}
      value={message}
      onChange={(e) => setMessage(e.target.value)}
      onSendMessage={(e) => {
        e.preventDefault();
        console.log('Message:', message);
        console.log('Selected contact:', selectedContact);
        setMessage('');
      }}
      typeaheadOptions={contactOptions}
      // ✅ Required: Convert object to string for display
      getTypeaheadValueString={(contact) => `${contact.type}_${contact.id}`}
      // ✅ Optional: Custom serialization for storage
      serializeTypeaheadValue={(contact) =>
        JSON.stringify({
          id: contact.id,
          type: contact.type,
          name: contact.name,
        })
      }
      // ✅ Access fully typed object on selection!
      onTypeaheadSelect={(option) => {
        const contact = option.value; // Fully typed CRMContact
        setSelectedContact(contact);
        console.log('Selected contact:', contact.email, contact.metadata.status);
      }}
      placeholder="Type @ to mention a contact..."
    />
  );
}

Type Safety Benefits:

  • Full TypeScript autocomplete on option.value
  • No type assertions needed
  • Compile-time error checking
  • IDE hints for object properties

Typeahead Configuration

OptionDescription
typeaheadOptionsFlat array or grouped array of options
onTypeaheadQueryChangeCallback when search query changes (for dynamic loading)
onTypeaheadSelectCallback when option is selected (provides typed value)
getTypeaheadValueStringConvert typed value to string (required for objects)
serializeTypeaheadValueSerialize value for chip storage (defaults to JSON.stringify)

Programmatic Control via Ref

The Chat Input exposes an imperative API via ref for advanced control:

import { useRef } from 'react';
import { ChatInput, type ChatInputHandle } from '@camp/chat-input';
 
function ControlledChat() {
  const inputRef = useRef<ChatInputHandle>(null);
 
  return (
    <>
      <button onClick={() => inputRef.current?.expand()}>Expand</button>
      <button onClick={() => inputRef.current?.collapse()}>Collapse</button>
      <button onClick={() => inputRef.current?.focus()}>Focus</button>
      <button
        onClick={() => {
          inputRef.current?.insertChip('VIP Customer', 'tag:vip', <TagIcon />);
        }}
      >
        Insert Tag Chip
      </button>
      <button
        onClick={() => {
          inputRef.current?.insertLinkChip('Documentation', 'https://example.com');
        }}
      >
        Insert Link Chip
      </button>
 
      <ChatInput
        ref={inputRef}
        appearance="expandable"
        value={message}
        onChange={(e) => setMessage(e.target.value)}
        onSendMessage={handleSend}
      />
    </>
  );
}

Ref API Methods

MethodSignatureDescription
insertChip(label: string, value: string, icon?: ReactNode) => voidInsert a chip (mention/tag) at cursor
insertLinkChip(label: string, url: string) => voidInsert a link chip at cursor
focus() => voidFocus the input editor
expand() => voidExpand input (expandable appearance only)
collapse() => voidCollapse input (expandable appearance only)
isExpandedbooleanCurrent expanded state (expandable appearance only)

Common Ref Use Cases

1. Insert Context Programmatically

const handleAddContext = (contextItem: ContextItem) => {
  inputRef.current?.insertChip(contextItem.label, contextItem.value, contextItem.icon);
  inputRef.current?.focus(); // Keep focus after insertion
};

2. Auto-Expand on Drag Enter

const handleDragEnter = () => {
  if (inputRef.current && !inputRef.current.isExpanded) {
    inputRef.current.expand();
    wasExpandedByDrag.current = true;
  }
};

3. External Expansion Control

<ChatInput
  appearance="expandable"
  value={message}
  onChange={(e) => setMessage(e.target.value)}
  onSendMessage={handleSend}
  isExpanded={isExpanded} // Controlled
  onExpandedChange={setIsExpanded} // Controlled
/>

Companion components

The Chat Input component includes companion components designed for creating complete, polished chat experiences, particularly when using the expandable appearance.

ChatInputBackground

A wrapper component that provides a gradient background and fade effect, ideal for hero sections or prominent chat interfaces.

ChatInputHeader

A heading component with gradient text styling that complements the Chat Input aesthetic.

ChatInputTextButton

A text-style button that matches the Chat Input design system, useful for secondary actions like "Start from scratch" or "View examples".

Complete composition example

import { useState } from 'react';
import {
  ChatInput,
  ChatInputBackground,
  ChatInputHeader,
  ChatInputTextButton,
} from '@camp/chat-input';
 
function ChatInterface() {
  const [message, setMessage] = useState('Create a marketing campaign for my new product');
  const [isExpanded, setIsExpanded] = useState(true);
 
  const handleSendMessage = (e) => {
    e.preventDefault();
    console.log('Sending:', message);
    setMessage('');
  };
 
  return (
    <ChatInputBackground>
      <ChatInputHeader>Generate an automation in seconds</ChatInputHeader>
      <ChatInput
        appearance="expandable"
        value={message}
        onChange={(e) => setMessage(e.target.value)}
        onSendMessage={handleSendMessage}
        placeholder={[
          'Create a campaign for my new product launch',
          'Generate a blog post about...',
          'Summarize this document...',
        ]}
        isExpanded={isExpanded}
        onExpandedChange={setIsExpanded}
      />
      <ChatInputTextButton onClick={() => console.log('Starting from scratch')}>
        Start from scratch instead
      </ChatInputTextButton>
    </ChatInputBackground>
  );
}

Pattern: URL Attachments with Rich Previews

This pattern demonstrates how to add URL attachment functionality to the chat input with automatic metadata fetching for rich link previews.

Features

  • 📎 URL input menu: NestedMenu with inline URL input field
  • 🔗 Auto URL detection: Paste URLs directly into chat input
  • 🖼️ Rich previews: Automatic metadata fetching (title, description, image)
  • Smart labeling: Extracts domain names as labels
  • 🗑️ Attachment management: Remove links individually
  • ⌨️ Form validation: URL validation before adding attachments

Complete Implementation

import React, { useState, useRef, useMemo, useEffect, useCallback } from 'react';
import {
  ChatInput,
  type ChatInputHandle,
  AttachmentsSection,
  type LinkAttachment,
  type LinkMetadata,
} from '@camp/chat-input';
import { AttachmentMedium, LinkSmall } from '@camp/icon';
import { AiButton } from '@camp/ai-button';
import { Text } from '@camp/text';
import type { ItemProps } from '@camp/nested-menu';
import { NestedMenu, VALUE_TYPE } from '@camp/nested-menu';
 
function URLAttachmentsPattern() {
  const [linkAttachments, setLinkAttachments] = useState<LinkAttachment[]>([]);
  const [chatInputState, setChatInputState] = useState<string>('');
  const [urlInputState, setUrlInputState] = useState<string>('');
  const [linkMetadata, setLinkMetadata] = useState<Map<string, LinkMetadata>>(new Map());
  const [linkMetadataLoading, setLinkMetadataLoading] = useState<Map<string, boolean>>(new Map());
  const linkMetadataFailedRef = useRef<Set<string>>(new Set());
  const inputRef = useRef<ChatInputHandle>(null);
 
  // Fetch metadata for link attachments
  useEffect(() => {
    linkAttachments.forEach((link) => {
      // Skip if already loaded, loading, or previously failed
      if (
        linkMetadata.has(link.url) ||
        linkMetadataLoading.get(link.url) ||
        linkMetadataFailedRef.current.has(link.url)
      ) {
        return;
      }
 
      // Set loading state
      setLinkMetadataLoading((prev) => new Map(prev).set(link.url, true));
 
      // Fetch metadata (simulated API call - replace with your own)
      fetch(`https://api.microlink.io/?url=${encodeURIComponent(link.url)}`)
        .then((res) => res.json())
        .then((data) => {
          if (data.status === 'success' && data.data) {
            setLinkMetadata((prev) =>
              new Map(prev).set(link.url, {
                title: data.data.title,
                description: data.data.description,
                image: data.data.image?.url,
              }),
            );
          } else {
            // Mark as failed if API returns error status
            linkMetadataFailedRef.current.add(link.url);
          }
        })
        .catch((error) => {
          console.error('Failed to fetch metadata:', error);
          // Mark as failed so we don't retry
          linkMetadataFailedRef.current.add(link.url);
        })
        .finally(() => {
          setLinkMetadataLoading((prev) => {
            const next = new Map(prev);
            next.delete(link.url);
            return next;
          });
        });
    });
  }, [linkMetadata, linkMetadataLoading, linkAttachments]);
 
  // Handle URL paste - extracts a label from the URL
  const handleUrlPaste = useCallback((url: string) => {
    console.log('URL pasted:', url);
    try {
      // Extract a user-friendly label from the URL (domain name)
      const urlObj = new URL(url);
      const label = urlObj.hostname.replace(/^www\./, '');
      setLinkAttachments((prev) => [...prev, { label, url }]);
    } catch {
      // Fallback to using the URL as the label if parsing fails
      setLinkAttachments((prev) => [...prev, { label: url, url }]);
    }
  }, []);
 
  const handleRemoveLink = useCallback(
    (index: number) => setLinkAttachments((prev) => prev.filter((_, i) => i !== index)),
    [],
  );
 
  // Helper to format URL
  const formatUrl = (url: string): string => {
    const trimmed = url.trim();
    return trimmed.startsWith('http') ? trimmed : `https://${trimmed}`;
  };
 
  // Helper to extract domain from URL
  const getDomainFromUrl = (url: string): string => {
    try {
      const urlObj = new URL(url);
      return urlObj.hostname.replace(/^www\./, '');
    } catch {
      return url;
    }
  };
 
  // URL input menu options - structured as MENU_GROUP with INPUT
  const inputOptions: ItemProps<string>[] = useMemo(
    () => [
      {
        type: VALUE_TYPE.MENU_GROUP,
        hideBackButton: true,
        customContent: {
          leftIcon: <LinkSmall title="Add URL" style={{ marginRight: '8px' }} />,
        },
        value: 'Add a URL',
        items: [
          {
            type: VALUE_TYPE.INPUT,
            value: '',
            autofocus: true,
            inputProps: {
              placeholder: 'https://example.com',
              type: 'url',
              onInputChange: (value: string) => {
                setUrlInputState(value);
              },
              onInputSubmit: (inputValue: string) => {
                const formattedUrl = formatUrl(inputValue);
                if (formattedUrl) {
                  const domain = getDomainFromUrl(formattedUrl);
                  setLinkAttachments((prev) => [...prev, { label: domain, url: formattedUrl }]);
                  setUrlInputState('');
                }
              },
            },
          },
        ],
      },
    ],
    [],
  );
 
  // Render menu actions for URL input
  const renderMenuActions = useCallback(
    (_closeMenu: () => void, { currentMenuItem, submitInput }: any) => {
      if (currentMenuItem && currentMenuItem.value === 'Add a URL') {
        return (
          <AiButton
            appearance="secondary"
            size="medium"
            style={{ width: '100%' }}
            disabled={!urlInputState.length}
            onClick={() => {
              submitInput?.();
            }}
          >
            <Text color="default">Add Link</Text>
          </AiButton>
        );
      }
      return null;
    },
    [urlInputState],
  );
 
  // Render attachments section
  const renderAttachments = useCallback(
    () =>
      linkAttachments.length > 0 && (
        <AttachmentsSection
          links={linkAttachments}
          onRemoveLink={handleRemoveLink}
          linkMetadata={linkMetadata}
          linkMetadataLoading={linkMetadataLoading}
        />
      ),
    [linkAttachments, handleRemoveLink, linkMetadata, linkMetadataLoading],
  );
 
  // Render actions section with URL input menu
  const renderActions = useCallback(
    () => (
      <NestedMenu
        selected={chatInputState}
        showChevrons={false}
        search={false}
        items={inputOptions}
        styles={{
          minWidth: '275px',
        }}
        renderMenuActions={renderMenuActions}
      >
        <AiButton appearance="secondary" size="small" style={{ padding: '6px' }}>
          <AttachmentMedium title="Add URL" style={{ margin: 0 }} />
        </AiButton>
      </NestedMenu>
    ),
    [chatInputState, inputOptions, renderMenuActions],
  );
 
  return (
    <ChatInput
      ref={inputRef}
      appearance="expandable"
      value={chatInputState}
      renderActions={renderActions}
      renderAttachments={renderAttachments}
      placeholder="Paste URLs here or use the attachment button..."
      onChange={(e) => setChatInputState(e.target.value)}
      onSendMessage={(e) => {
        e.preventDefault();
        console.log('Submitted:', chatInputState, { links: linkAttachments });
        setChatInputState('');
        setLinkAttachments([]);
      }}
      onUrlPaste={handleUrlPaste}
    />
  );
}

Key Implementation Details

1. URL Input Menu with NestedMenu

Use a MENU_GROUP with hideBackButton containing an INPUT item:

const inputOptions: ItemProps<string>[] = useMemo(
  () => [
    {
      type: VALUE_TYPE.MENU_GROUP,
      hideBackButton: true,
      customContent: {
        leftIcon: <LinkSmall title="Add URL" />,
      },
      value: 'Add a URL',
      items: [
        {
          type: VALUE_TYPE.INPUT,
          value: '',
          autofocus: true,
          inputProps: {
            placeholder: 'https://example.com',
            type: 'url',
            onInputChange: (value: string) => setUrlInputState(value),
            onInputSubmit: (inputValue: string) => {
              const formattedUrl = formatUrl(inputValue);
              const domain = getDomainFromUrl(formattedUrl);
              setLinkAttachments((prev) => [...prev, { label: domain, url: formattedUrl }]);
              setUrlInputState('');
            },
          },
        },
      ],
    },
  ],
  [],
);

Key points:

  • hideBackButton: true prevents showing a back button in the menu
  • autofocus: true automatically focuses the input when menu opens
  • type: 'url' enables browser URL validation

2. Menu Actions Button

Add a submit button that uses the submitInput callback:

const renderMenuActions = useCallback(
  (_closeMenu: () => void, { currentMenuItem, submitInput }: any) => {
    if (currentMenuItem && currentMenuItem.value === 'Add a URL') {
      return (
        <AiButton
          appearance="secondary"
          size="medium"
          style={{ width: '100%' }}
          disabled={!urlInputState.length}
          onClick={() => submitInput?.()}
        >
          <Text color="default">Add Link</Text>
        </AiButton>
      );
    }
    return null;
  },
  [urlInputState],
);

3. URL Paste Detection

Enable automatic URL detection with the onUrlPaste prop:

const handleUrlPaste = useCallback((url: string) => {
  try {
    // Extract a user-friendly label from the URL (domain name)
    const urlObj = new URL(url);
    const label = urlObj.hostname.replace(/^www\./, '');
    setLinkAttachments((prev) => [...prev, { label, url }]);
  } catch {
    // Fallback to using the URL as the label if parsing fails
    setLinkAttachments((prev) => [...prev, { label: url, url }]);
  }
}, []);
 
// In ChatInput
<ChatInput onUrlPaste={handleUrlPaste} {...otherProps} />;

Fetch rich preview data for each URL (title, description, image):

useEffect(() => {
  linkAttachments.forEach((link) => {
    // Skip if already loaded, loading, or previously failed
    if (
      linkMetadata.has(link.url) ||
      linkMetadataLoading.get(link.url) ||
      linkMetadataFailedRef.current.has(link.url)
    ) {
      return;
    }
 
    setLinkMetadataLoading((prev) => new Map(prev).set(link.url, true));
 
    // Replace with your metadata API endpoint
    fetch(`https://api.microlink.io/?url=${encodeURIComponent(link.url)}`)
      .then((res) => res.json())
      .then((data) => {
        if (data.status === 'success' && data.data) {
          setLinkMetadata((prev) =>
            new Map(prev).set(link.url, {
              title: data.data.title,
              description: data.data.description,
              image: data.data.image?.url,
            }),
          );
        } else {
          linkMetadataFailedRef.current.add(link.url);
        }
      })
      .catch((error) => {
        console.error('Failed to fetch metadata:', error);
        linkMetadataFailedRef.current.add(link.url);
      })
      .finally(() => {
        setLinkMetadataLoading((prev) => {
          const next = new Map(prev);
          next.delete(link.url);
          return next;
        });
      });
  });
}, [linkMetadata, linkMetadataLoading, linkAttachments]);

Important: Use a ref (linkMetadataFailedRef) to track failed URLs and prevent infinite retry loops.

5. Attachments Display

Render attached links with the AttachmentsSection component:

const renderAttachments = useCallback(
  () =>
    linkAttachments.length > 0 && (
      <AttachmentsSection
        links={linkAttachments}
        onRemoveLink={handleRemoveLink}
        linkMetadata={linkMetadata}
        linkMetadataLoading={linkMetadataLoading}
      />
    ),
  [linkAttachments, handleRemoveLink, linkMetadata, linkMetadataLoading],
);

Metadata API Options

You can use various services for fetching link metadata:

Option 1: Microlink (used in example)

fetch(`https://api.microlink.io/?url=${encodeURIComponent(link.url)}`)
  .then((res) => res.json())
  .then((data) => {
    if (data.status === 'success' && data.data) {
      return {
        title: data.data.title,
        description: data.data.description,
        image: data.data.image?.url,
      };
    }
  });

Option 2: Your own backend endpoint

fetch(`/api/link-preview?url=${encodeURIComponent(link.url)}`)
  .then((res) => res.json())
  .then((data) => ({
    title: data.title,
    description: data.description,
    image: data.image,
  }));

Option 3: OpenGraph metadata scraper

// Implement server-side with libraries like:
// - open-graph-scraper (Node.js)
// - metascraper (Node.js)
// - unfurl (Python)

Best Practices

  1. Failed URL tracking: Use a ref to track failed metadata fetches and prevent infinite retries
  2. URL formatting: Auto-prepend https:// for URLs without protocol
  3. Domain extraction: Extract clean domain names for default labels
  4. Loading states: Show loading indicators while fetching metadata
  5. Error handling: Gracefully handle API failures without blocking the UI
  6. Memoization: Memoize callbacks and options to prevent unnecessary re-renders
  7. Cleanup: Clear attachments after successful message submission

Advanced Pattern: Chat Interface W/ Typeahead & Attachments

This section documents a comprehensive pattern that combines multiple camp components to create a fully-featured AI chat interface with attachments, context menus, tool selection, drag-and-drop file upload, and URL pasting.

Architecture Overview

This pattern integrates:

  • ChatInput (expandable) - Main chat interface
  • NestedMenu - For attachment options, context selection, and URL input
  • Dropdown - Tool selection with custom toggles and actions
  • Dropzone - Drag-and-drop file upload
  • AttachmentsSection - Display link and file attachments with metadata
  • Link metadata fetching - Automatic preview generation for pasted URLs
  • Typeahead with @ mentions - Autocomplete for context objects

Features

  • 📎 Multi-format attachments: Files and links with rich previews
  • 🎯 Context injection: @ mentions with grouped typeahead options
  • 🔧 Tool selection: Dropdown with toggles for external integrations
  • 🖱️ Drag & drop: Visual dropzone with automatic expansion
  • 🔗 Smart URL detection: Auto-attach pasted URLs with metadata
  • ⌨️ Keyboard shortcuts: Full keyboard navigation support
  • 🎨 Optimized performance: Proper memoization throughout

Complete Implementation

import React, { useState, useRef, useMemo, useEffect, useCallback } from 'react';
import {
  ChatInput,
  type ChatInputHandle,
  AttachmentsSection,
  type LinkAttachment,
  type LinkMetadata,
} from '@camp/chat-input';
import { CloseSmall, AttachmentMedium, AtSmall, RefineTogglesSmall } from '@camp/icon';
import { AiIconButton } from '@camp/ai-icon-button';
import { AiButton } from '@camp/ai-button';
import { Text } from '@camp/text';
import { NestedMenu } from '@camp/nested-menu';
import { Dropdown, DropdownMenuItem, type DropdownOption } from '@camp/dropdown';
import { Dropzone } from '@camp/dropzone';
 
function ProductionChatInterface() {
  // State management
  const [chatInputState, setChatInputState] = useState<string>('');
  const [selectedTool, setSelectedTool] = useState<ToolOption | undefined>();
  const [linkAttachments, setLinkAttachments] = useState<LinkAttachment[]>([]);
  const [fileAttachments, setFileAttachments] = useState<File[]>([]);
  const [urlInputState, setUrlInputState] = useState<string>('');
  const [urlInputError, setUrlInputError] = useState<string | null>(null);
  const [linkMetadata, setLinkMetadata] = useState<Map<string, LinkMetadata>>(new Map());
  const [linkMetadataLoading, setLinkMetadataLoading] = useState<Map<string, boolean>>(new Map());
 
  // Refs
  const linkMetadataFailedRef = useRef<Set<string>>(new Set());
  const fileInputRef = useRef<HTMLInputElement>(null);
  const inputRef = useRef<ChatInputHandle>(null);
  const wasExpandedByDrag = useRef(false);
 
  // Fetch link metadata when attachments change
  useEffect(() => {
    linkAttachments.forEach((link) => {
      if (
        linkMetadata.has(link.url) ||
        linkMetadataLoading.get(link.url) ||
        linkMetadataFailedRef.current.has(link.url)
      ) {
        return;
      }
 
      setLinkMetadataLoading((prev) => new Map(prev).set(link.url, true));
 
      // Replace with your metadata API
      fetch(`https://api.microlink.io/?url=${encodeURIComponent(link.url)}`)
        .then((res) => res.json())
        .then((data) => {
          if (data.status === 'success' && data.data) {
            setLinkMetadata((prev) =>
              new Map(prev).set(link.url, {
                title: data.data.title,
                description: data.data.description,
                image: data.data.image?.url,
              }),
            );
          } else {
            linkMetadataFailedRef.current.add(link.url);
          }
        })
        .catch(() => {
          linkMetadataFailedRef.current.add(link.url);
        })
        .finally(() => {
          setLinkMetadataLoading((prev) => {
            const next = new Map(prev);
            next.delete(link.url);
            return next;
          });
        });
    });
  }, [linkMetadata, linkMetadataLoading, linkAttachments]);
 
  // Attachment handlers (memoized)
  const handleAddLink = useCallback((label: string, url: string) => {
    setLinkAttachments((prev) => [...prev, { label, url }]);
  }, []);
 
  const handleUrlPaste = useCallback(
    (url: string) => {
      try {
        const urlObj = new URL(url);
        const label = urlObj.hostname.replace(/^www\./, '');
        handleAddLink(label, url);
      } catch {
        handleAddLink(url, url);
      }
    },
    [handleAddLink],
  );
 
  const handleRemoveLink = useCallback(
    (index: number) => setLinkAttachments((prev) => prev.filter((_, i) => i !== index)),
    [],
  );
 
  const handleRemoveFile = useCallback(
    (index: number) => setFileAttachments((prev) => prev.filter((_, i) => i !== index)),
    [],
  );
 
  const handleOpenFilePicker = useCallback(() => fileInputRef.current?.click(), []);
 
  const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files;
    if (files && files.length > 0) {
      setFileAttachments((prev) => [...prev, ...Array.from(files)]);
      e.target.value = ''; // Reset
    }
  }, []);
 
  const handleFileDrop = useCallback((files: File[]) => {
    setFileAttachments((prev) => [...prev, ...files]);
    wasExpandedByDrag.current = false;
  }, []);
 
  const handleDragEnter = useCallback(() => {
    if (inputRef.current && !inputRef.current.isExpanded) {
      inputRef.current.expand();
      wasExpandedByDrag.current = true;
    }
  }, []);
 
  const handleDragLeave = useCallback(() => {
    if (
      wasExpandedByDrag.current &&
      inputRef.current &&
      !chatInputState &&
      fileAttachments.length === 0 &&
      linkAttachments.length === 0
    ) {
      inputRef.current.collapse();
      wasExpandedByDrag.current = false;
    }
  }, [chatInputState, fileAttachments.length, linkAttachments.length]);
 
  // Memoized options for typeahead (@ mentions)
  const typeaheadOptions = useMemo(
    () => [
      {
        label: 'Contacts',
        items: [
          { value: 'All Contacts', icon: <ContactIcon /> },
          { value: 'Active Subscribers', icon: <ContactIcon /> },
        ],
      },
      {
        label: 'Campaigns',
        items: [
          { value: 'Recent Campaigns', icon: <CampaignIcon /> },
          { value: 'Top Performing', icon: <CampaignIcon /> },
        ],
      },
    ],
    [],
  );
 
  // Memoized attachment menu items with URL input
  const attachmentItems = useMemo(
    () => [
      {
        type: 'input',
        value: '',
        inputProps: {
          type: 'url',
          placeholder: 'Paste URL...',
          onInputChange: (val: string) => setUrlInputState(val),
          onInputSubmit: (val: string) => {
            handleAddLink('Link', val);
            setUrlInputState('');
          },
          validate: (val: string) => {
            try {
              new URL(val);
              return true;
            } catch {
              return 'Invalid URL';
            }
          },
        },
      },
      { type: 'section-header', value: 'or' },
      {
        type: 'selectable',
        value: 'Upload files',
        onClick: handleOpenFilePicker,
      },
    ],
    [handleAddLink, handleOpenFilePicker],
  );
 
  // Memoized context menu items
  const contextItems = useMemo(
    () =>
      typeaheadOptions.map((group) => ({
        type: 'menu-group',
        value: group.label,
        items: group.items.map((item) => ({
          type: 'selectable',
          value: item.value,
        })),
      })),
    [typeaheadOptions],
  );
 
  // Tool selection dropdown options
  const toolOptions = useMemo(
    () => [
      { label: 'Wix', value: 'wix', icon: <WixIcon /> },
      { label: 'Zendesk', value: 'zendesk', icon: <ZendeskIcon /> },
      { label: 'Google Ads', value: 'google-ads', icon: <GoogleIcon /> },
    ],
    [],
  );
 
  // Render menu actions for URL input (memoized)
  const renderMenuActions = useCallback(
    (_closeMenu: () => void, { submitInput }: any) => (
      <AiButton
        appearance="secondary"
        size="medium"
        style={{ width: '100%' }}
        disabled={!urlInputState.length || !!urlInputError}
        onClick={() => submitInput?.()}
      >
        Add Link
      </AiButton>
    ),
    [urlInputState, urlInputError],
  );
 
  // Render attachments section (memoized)
  const renderAttachments = useCallback(
    () =>
      (linkAttachments.length > 0 || fileAttachments.length > 0) && (
        <AttachmentsSection
          links={linkAttachments}
          files={fileAttachments}
          onRemoveLink={handleRemoveLink}
          onRemoveFile={handleRemoveFile}
          linkMetadata={linkMetadata}
          linkMetadataLoading={linkMetadataLoading}
        />
      ),
    [
      linkAttachments,
      fileAttachments,
      handleRemoveLink,
      handleRemoveFile,
      linkMetadata,
      linkMetadataLoading,
    ],
  );
 
  // Handle context selection (insert @ mention chip)
  const handleContextSelect = useCallback(
    (option: unknown) => {
      if (!option) return;
      const optionStr = String(option);
      const group = typeaheadOptions.find((g) => g.items.some((item) => item.value === optionStr));
      const item = group?.items.find((i) => i.value === optionStr);
      inputRef.current?.insertChip(optionStr, optionStr, item?.icon);
    },
    [typeaheadOptions],
  );
 
  // Render actions section (memoized)
  const renderActions = useCallback(
    () => (
      <>
        {/* Attachment menu */}
        <NestedMenu
          showChevrons={false}
          search={false}
          items={attachmentItems}
          styles={{ minWidth: '275px' }}
          renderMenuActions={renderMenuActions}
        >
          <AiButton appearance="secondary" size="small">
            <AttachmentMedium title="Attach" />
          </AiButton>
        </NestedMenu>
 
        {/* Tool selection dropdown */}
        <Dropdown
          type="single"
          value={selectedTool}
          onSelect={setSelectedTool}
          options={toolOptions}
          minMenuWidth="300px"
          showSearch
          renderTrigger={(props) => (
            <AiButton size="small" appearance="secondary" {...props}>
              <RefineTogglesSmall title="Tools" />
              <Text appearance="body-small">{selectedTool?.label ?? 'Tools'}</Text>
              {selectedTool && (
                <AiIconButton
                  appearance="transparent"
                  size="small"
                  onClick={(e) => {
                    e.stopPropagation();
                    setSelectedTool(undefined);
                  }}
                >
                  <CloseSmall title="Clear" />
                </AiIconButton>
              )}
            </AiButton>
          )}
        />
 
        {/* Context menu */}
        <NestedMenu
          showChevrons={false}
          searchPlaceholder="Search context..."
          items={contextItems}
          onSelectItem={handleContextSelect}
        >
          <AiButton appearance="secondary" size="small">
            <AtSmall title="Add context" />
            <Text appearance="body-small">Add context</Text>
          </AiButton>
        </NestedMenu>
      </>
    ),
    [
      attachmentItems,
      renderMenuActions,
      selectedTool,
      toolOptions,
      contextItems,
      handleContextSelect,
    ],
  );
 
  return (
    <>
      <input
        ref={fileInputRef}
        type="file"
        multiple
        accept="image/*,.pdf,.csv"
        style={{ display: 'none' }}
        onChange={handleFileChange}
      />
      <Dropzone
        allowMultiple
        title="Drop files to attach"
        validateFiles={handleFileDrop}
        acceptedFileTypes="image/*,.pdf,.csv"
        maxWidth="800px"
        onDragEnter={handleDragEnter}
        onDragLeave={handleDragLeave}
      >
        <ChatInput
          ref={inputRef}
          appearance="expandable"
          value={chatInputState}
          renderActions={renderActions}
          typeaheadOptions={typeaheadOptions}
          renderAttachments={renderAttachments}
          placeholder="Type @ for context, paste URLs, or drag files..."
          onChange={(e) => setChatInputState(e.target.value)}
          onSendMessage={(e) => {
            console.log('Submitted:', chatInputState, {
              links: linkAttachments,
              files: fileAttachments,
              tool: selectedTool,
            });
            setChatInputState('');
            setLinkAttachments([]);
            setFileAttachments([]);
          }}
          onUrlPaste={handleUrlPaste}
        />
      </Dropzone>
    </>
  );
}

Key Implementation Details

The pattern automatically fetches rich preview metadata for pasted URLs:

useEffect(() => {
  linkAttachments.forEach((link) => {
    // Skip if already loaded, loading, or failed
    if (
      linkMetadata.has(link.url) ||
      linkMetadataLoading.get(link.url) ||
      linkMetadataFailedRef.current.has(link.url)
    ) {
      return;
    }
 
    setLinkMetadataLoading((prev) => new Map(prev).set(link.url, true));
 
    // Fetch from your metadata API
    fetch(`/api/link-preview?url=${encodeURIComponent(link.url)}`)
      .then((res) => res.json())
      .then((data) => {
        setLinkMetadata((prev) =>
          new Map(prev).set(link.url, {
            title: data.title,
            description: data.description,
            image: data.image,
          }),
        );
      })
      .catch(() => linkMetadataFailedRef.current.add(link.url))
      .finally(() => {
        setLinkMetadataLoading((prev) => {
          const next = new Map(prev);
          next.delete(link.url);
          return next;
        });
      });
  });
}, [linkAttachments, linkMetadata, linkMetadataLoading]);

Important: Use a ref (linkMetadataFailedRef) for failed URLs to prevent infinite retry loops.

2. Smart Drag & Drop Expansion

The dropzone automatically expands the chat input when files are dragged over, and collapses when empty:

const wasExpandedByDrag = useRef(false);
 
const handleDragEnter = useCallback(() => {
  if (inputRef.current && !inputRef.current.isExpanded) {
    inputRef.current.expand();
    wasExpandedByDrag.current = true; // Track that we expanded
  }
}, []);
 
const handleDragLeave = useCallback(() => {
  // Only collapse if:
  // 1. We expanded it via drag
  // 2. Input is empty
  // 3. No attachments
  if (
    wasExpandedByDrag.current &&
    inputRef.current &&
    !chatInputState &&
    fileAttachments.length === 0 &&
    linkAttachments.length === 0
  ) {
    inputRef.current.collapse();
    wasExpandedByDrag.current = false;
  }
}, [chatInputState, fileAttachments.length, linkAttachments.length]);
 
const handleFileDrop = useCallback((files: File[]) => {
  setFileAttachments((prev) => [...prev, ...files]);
  wasExpandedByDrag.current = false; // Keep expanded after drop
}, []);

3. URL Input with Validation

The attachment menu includes a URL input with inline validation:

const attachmentItems = useMemo(
  () => [
    {
      type: 'input',
      value: '',
      inputProps: {
        type: 'url',
        placeholder: 'Paste URL...',
        onInputChange: (val: string) => setUrlInputState(val),
        onInputSubmit: (val: string) => {
          handleAddLink('Link', val);
          setUrlInputState('');
        },
        validate: (val: string) => {
          try {
            new URL(val);
            return true;
          } catch {
            return 'Invalid URL';
          }
        },
      },
    },
    { type: 'section-header', value: 'or' },
    {
      type: 'selectable',
      value: 'Upload files',
      onClick: handleOpenFilePicker,
    },
  ],
  [handleAddLink, handleOpenFilePicker],
);

4. Context Chips with @ Mentions

Typeahead options are grouped and searchable. When selected, they're inserted as chips:

const handleContextSelect = useCallback(
  (option: unknown) => {
    if (!option) return;
    const optionStr = String(option);
 
    // Find the icon for this option
    const group = typeaheadOptions.find((g) => g.items.some((item) => item.value === optionStr));
    const item = group?.items.find((i) => i.value === optionStr);
 
    // Insert chip with icon using ref handle
    inputRef.current?.insertChip(optionStr, optionStr, item?.icon);
  },
  [typeaheadOptions],
);

5. Tool Selection with Custom Trigger

The dropdown allows tool selection with a clearable custom trigger:

<Dropdown
  type="single"
  value={selectedTool}
  onSelect={setSelectedTool}
  options={toolOptions}
  renderTrigger={(props) => (
    <AiButton size="small" appearance="secondary" {...props}>
      <RefineTogglesSmall title="Tools" />
      <Text appearance="body-small">{selectedTool?.label ?? 'Tools'}</Text>
      {selectedTool && (
        <AiIconButton
          appearance="transparent"
          size="small"
          onClick={(e) => {
            e.stopPropagation(); // Prevent dropdown toggle
            setSelectedTool(undefined);
          }}
        >
          <CloseSmall title="Clear" />
        </AiIconButton>
      )}
    </AiButton>
  )}
/>

Key: Use e.stopPropagation() on the clear button to prevent opening the dropdown.

6. Performance Optimization

All callback props and complex objects are memoized to prevent unnecessary re-renders:

// Memoize handlers
const handleAddLink = useCallback((label: string, url: string) => {
  setLinkAttachments((prev) => [...prev, { label, url }]);
}, []);
 
// Memoize options
const typeaheadOptions = useMemo(() => [...], []);
const attachmentItems = useMemo(() => [...], [handleAddLink, handleOpenFilePicker]);
 
// Memoize render functions
const renderAttachments = useCallback(() => ..., [linkAttachments, fileAttachments, ...]);
const renderActions = useCallback(() => ..., [attachmentItems, selectedTool, ...]);

Best Practices

  1. State Management: Keep attachment state separate from input value state
  2. Metadata Fetching: Track failed URLs to prevent infinite retries
  3. Ref Usage: Use ChatInputHandle ref for programmatic control (expand/collapse/insertChip)
  4. Memoization: Memoize all callbacks and complex objects passed to child components
  5. Hidden File Input: Use a hidden <input type="file"> triggered by buttons or drag-and-drop
  6. Cleanup: Clear attachment state after successful submission
  7. Accessibility: Ensure all interactive elements have proper keyboard support

Integration Points

ComponentPurposeKey Props
ChatInputMain input interfaceref, renderActions, renderAttachments, typeaheadOptions, onUrlPaste
NestedMenuAttachment, context menusitems, renderMenuActions, onSelectItem
DropdownTool selectionrenderTrigger, onSelect, options
DropzoneDrag-and-drop wrappervalidateFiles, onDragEnter, onDragLeave
AttachmentsSectionDisplay attachmentslinks, files, linkMetadata, linkMetadataLoading

Props Reference

ChatInput Props

Common Props (All Appearances)

PropTypeDefaultDescription
valuestringRequiredControlled value of the textarea
onChange(e: ChangeEvent<HTMLTextAreaElement>) => voidRequiredCallback when value changes
onSendMessage(e: FormEvent<HTMLFormElement>) => voidRequiredCallback when form is submitted
appearance'default' | 'compact' | 'expandable''default'Visual appearance of the input
placeholderstring (default/compact)
string | string[] (expandable)
Placeholder text. Expandable supports array for rotation
isLoadingbooleanfalseShows loading state, disables input
disabledbooleanfalseDisables the input entirely
labelstringAccessibility label for screen readers
hideSendButtonbooleanfalseHides the send button
themedbooleantrueEnable theme-aware styling (light/dark modes)
renderActions() => ReactNodeRender function for action buttons (attachments, tools, etc.)
renderAttachments() => ReactNodeRender function for attachments section above input

Typeahead Props (@ Mentions)

PropTypeDefaultDescription
typeaheadOptionsTypeaheadOptionData<T>[] | TypeaheadGroup<T>[]Flat or grouped options for @ mention autocomplete
onTypeaheadQueryChange(query: string) => voidCallback when typeahead search query changes
onTypeaheadSelect(option: TypeaheadOptionData<T>) => voidCallback when typeahead option is selected
getTypeaheadValueString(value: T) => stringRequired for objects: Convert typed value to string for display
serializeTypeaheadValue(value: T) => stringJSON.stringifySerialize typed value for chip storage

URL Detection Props

PropTypeDefaultDescription
onUrlPaste(url: string) => voidCallback when valid URL is pasted. If provided, intercepts URL pastes

Expandable-Specific Props

PropTypeDefaultDescription
placeholderIntervalnumber3000Milliseconds between placeholder rotations (when placeholder is array)
isExpandedbooleanControlled: External expanded state
onExpandedChange(isExpanded: boolean) => voidControlled: Callback when expanded state changes
showThinkerbooleanfalseShows thinking animation

TypeaheadOptionData<T>

Structure for individual typeahead options:

interface TypeaheadOptionData<T = string> {
  label: string; // Display text
  value: T; // Typed value (string, object, etc.)
  keywords?: string[]; // Search keywords for filtering
  icon?: ReactNode; // Optional icon displayed with the option
}

TypeaheadGroup<T>

Structure for grouped typeahead options (shows nested menu on @):

interface TypeaheadGroup<T = string> {
  category: string; // Group name
  icon?: ReactNode; // Optional icon for the category
  items: TypeaheadOptionData<T>[]; // Options in this group
}

ChatInputHandle (Ref API)

interface ChatInputHandle {
  insertChip: (label: string, value: string, icon?: ReactNode) => void;
  insertLinkChip: (label: string, url: string) => void;
  focus: () => void;
  expand: () => void;
  collapse: () => void;
  isExpanded: boolean;
}

Companion Component Props

ChatInputBackground

PropTypeDefaultDescription
themedbooleantrueEnable theme-aware styling
childrenReactNodeRequiredContent (typically ChatInput, header, buttons)

ChatInputHeader

PropTypeDefaultDescription
childrenReactNodeRequiredHeader text or content

ChatInputTextButton

PropTypeDefaultDescription
themedbooleantrueEnable theme-aware styling
childrenReactNodeRequiredButton text
onClick() => voidClick handler

Copyright © 2026 ActiveCampaign