Skip to Content

Chat Input

Loading...

A specialized input component designed for AI chat interfaces that combines a textarea with a send button, loading states, and expandable interactions. 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/ai-chat-input

Variations

Default appearance

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

import { useState } from 'react'; import { AiChatInput } from '@camp/ai-chat-input'; function ChatComponent() { const [message, setMessage] = useState(''); const handleSendMessage = (e) => { e.preventDefault(); // Handle sending message console.log('Sending:', message); setMessage(''); }; return ( <AiChatInput 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 { AiChatInput } from '@camp/ai-chat-input'; function ChatComponent() { const [message, setMessage] = useState(''); const [isExpanded, setIsExpanded] = useState(false); const handleSendMessage = (e) => { e.preventDefault(); console.log('Sending:', message); setMessage(''); }; return ( <AiChatInput 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 { AiChatInput } from '@camp/ai-chat-input'; function ChatComponent() { const [message, setMessage] = useState(''); const [isExpanded, setIsExpanded] = useState(false); const handleSendMessage = (e) => { e.preventDefault(); console.log('Sending:', message); setMessage(''); }; return ( <AiChatInput 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 { AiChatInput } from '@camp/ai-chat-input'; function ChatComponent() { const [message, setMessage] = useState(''); const [isExpanded, setIsExpanded] = useState(false); return ( <AiChatInput 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 { AiChatInput } from '@camp/ai-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 ( <AiChatInput 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 { AiChatInput } from '@camp/ai-chat-input'; function ChatComponent() { const [message, setMessage] = useState('This input is disabled'); return ( <AiChatInput 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 { AiChatInput } from '@camp/ai-chat-input'; function ChatComponent() { const [message, setMessage] = useState(''); const handleSendMessage = (e) => { e.preventDefault(); console.log('Sending:', message); setMessage(''); }; return ( <AiChatInput 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 { AiChatInput } from '@camp/ai-chat-input'; function CompactChat() { const [message, setMessage] = useState(''); return ( <AiChatInput appearance="compact" value={message} onChange={(e) => setMessage(e.target.value)} onSendMessage={(e) => { e.preventDefault(); console.log('Sending:', message); setMessage(''); }} placeholder="Ask me anything..." /> ); }

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 { AiChatInput } from '@camp/ai-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 ( <AiChatInput 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 { AiChatInput, type TypeaheadGroup } from '@camp/ai-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 ( <AiChatInput 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 { AiChatInput, type TypeaheadGroup, type AiChatInputHandle } from '@camp/ai-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<AiChatInputHandle>(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 ( <AiChatInput<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 { AiChatInput, type AiChatInputHandle } from '@camp/ai-chat-input'; function ControlledChat() { const inputRef = useRef<AiChatInputHandle>(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> <AiChatInput 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

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

Companion components

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

AiChatInputBackground

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

AiChatInputHeader

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

AiChatInputTextButton

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

Complete composition example

import { useState } from 'react'; import { AiChatInput, AiChatInputBackground, AiChatInputHeader, AiChatInputTextButton, } from '@camp/ai-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 ( <AiChatInputBackground> <AiChatInputHeader>Generate an automation in seconds</AiChatInputHeader> <AiChatInput 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} /> <AiChatInputTextButton onClick={() => console.log('Starting from scratch')}> Start from scratch instead </AiChatInputTextButton> </AiChatInputBackground> ); }

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 { AiChatInput, type AiChatInputHandle, AttachmentsSection, type LinkAttachment, type LinkMetadata, } from '@camp/ai-chat-input'; import { AttachmentMedium, LinkSmall } from '@camp/icon'; import { AiButton } from '@camp/ai-button'; import { AiText } from '@camp/ai-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<AiChatInputHandle>(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?.(); }} > <AiText color="default">Add Link</AiText> </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 ( <AiChatInput 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?.()} > <AiText color="default">Add Link</AiText> </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 AiChatInput <AiChatInput 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:

  • AiChatInput (expandable) - Main chat interface
  • NestedMenu - For attachment options, context selection, and URL input
  • AiDropdown - Tool selection with custom toggles and actions
  • AiDropzone - 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 { AiChatInput, type AiChatInputHandle, AttachmentsSection, type LinkAttachment, type LinkMetadata, } from '@camp/ai-chat-input'; import { CloseSmall, AttachmentMedium, AtSmall, RefineTogglesSmall } from '@camp/icon'; import { AiIconButton } from '@camp/ai-icon-button'; import { AiButton } from '@camp/ai-button'; import { AiText } from '@camp/ai-text'; import { NestedMenu } from '@camp/nested-menu'; import { AiDropdown, DropdownMenuItem, type DropdownOption } from '@camp/ai-dropdown'; import { AiDropzone } from '@camp/ai-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<AiChatInputHandle>(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 */} <AiDropdown type="single" value={selectedTool} onSelect={setSelectedTool} options={toolOptions} minMenuWidth="300px" showSearch renderTrigger={(props) => ( <AiButton size="small" appearance="secondary" {...props}> <RefineTogglesSmall title="Tools" /> <AiText appearance="body-small">{selectedTool?.label ?? 'Tools'}</AiText> {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" /> <AiText appearance="body-small">Add context</AiText> </AiButton> </NestedMenu> </> ), [ attachmentItems, renderMenuActions, selectedTool, toolOptions, contextItems, handleContextSelect, ], ); return ( <> <input ref={fileInputRef} type="file" multiple accept="image/*,.pdf,.csv" style={{ display: 'none' }} onChange={handleFileChange} /> <AiDropzone allowMultiple title="Drop files to attach" validateFiles={handleFileDrop} acceptedFileTypes="image/*,.pdf,.csv" maxWidth="800px" onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} > <AiChatInput 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} /> </AiDropzone> </> ); }

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:

<AiDropdown type="single" value={selectedTool} onSelect={setSelectedTool} options={toolOptions} renderTrigger={(props) => ( <AiButton size="small" appearance="secondary" {...props}> <RefineTogglesSmall title="Tools" /> <AiText appearance="body-small">{selectedTool?.label ?? 'Tools'}</AiText> {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 AiChatInputHandle 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
AiChatInputMain input interfaceref, renderActions, renderAttachments, typeaheadOptions, onUrlPaste
NestedMenuAttachment, context menusitems, renderMenuActions, onSelectItem
AiDropdownTool selectionrenderTrigger, onSelect, options
AiDropzoneDrag-and-drop wrappervalidateFiles, onDragEnter, onDragLeave
AttachmentsSectionDisplay attachmentslinks, files, linkMetadata, linkMetadataLoading

Props Reference

AiChatInput 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
showAiThinkerbooleanfalseShows AI 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 }

AiChatInputHandle (Ref API)

interface AiChatInputHandle { 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

AiChatInputBackground

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

AiChatInputHeader

PropTypeDefaultDescription
childrenReactNodeRequiredHeader text or content

AiChatInputTextButton

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

Last updated on