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
Install
yarn add @camp/ai-chat-inputVariations
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:
- Type
@→ Shows nested menu with categories - Continue typing (
@act) → Flattens and filters across all items using keywords - 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
| Option | Description |
|---|---|
typeaheadOptions | Flat array or grouped array of options |
onTypeaheadQueryChange | Callback when search query changes (for dynamic loading) |
onTypeaheadSelect | Callback when option is selected (provides typed value) |
getTypeaheadValueString | Convert typed value to string (required for objects) |
serializeTypeaheadValue | Serialize 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
| Method | Signature | Description |
|---|---|---|
insertChip | (label: string, value: string, icon?: ReactNode) => void | Insert a chip (mention/tag) at cursor |
insertLinkChip | (label: string, url: string) => void | Insert a link chip at cursor |
focus | () => void | Focus the input editor |
expand | () => void | Expand input (expandable appearance only) |
collapse | () => void | Collapse input (expandable appearance only) |
isExpanded | boolean | Current 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: trueprevents showing a back button in the menuautofocus: trueautomatically focuses the input when menu openstype: '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} />;4. Link Metadata Fetching
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
- Failed URL tracking: Use a ref to track failed metadata fetches and prevent infinite retries
- URL formatting: Auto-prepend
https://for URLs without protocol - Domain extraction: Extract clean domain names for default labels
- Loading states: Show loading indicators while fetching metadata
- Error handling: Gracefully handle API failures without blocking the UI
- Memoization: Memoize callbacks and options to prevent unnecessary re-renders
- 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
1. Link Metadata Fetching
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
- State Management: Keep attachment state separate from input value state
- Metadata Fetching: Track failed URLs to prevent infinite retries
- Ref Usage: Use
AiChatInputHandleref for programmatic control (expand/collapse/insertChip) - Memoization: Memoize all callbacks and complex objects passed to child components
- Hidden File Input: Use a hidden
<input type="file">triggered by buttons or drag-and-drop - Cleanup: Clear attachment state after successful submission
- Accessibility: Ensure all interactive elements have proper keyboard support
Integration Points
| Component | Purpose | Key Props |
|---|---|---|
AiChatInput | Main input interface | ref, renderActions, renderAttachments, typeaheadOptions, onUrlPaste |
NestedMenu | Attachment, context menus | items, renderMenuActions, onSelectItem |
AiDropdown | Tool selection | renderTrigger, onSelect, options |
AiDropzone | Drag-and-drop wrapper | validateFiles, onDragEnter, onDragLeave |
AttachmentsSection | Display attachments | links, files, linkMetadata, linkMetadataLoading |
Props Reference
AiChatInput Props
Common Props (All Appearances)
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | Required | Controlled value of the textarea |
onChange | (e: ChangeEvent<HTMLTextAreaElement>) => void | Required | Callback when value changes |
onSendMessage | (e: FormEvent<HTMLFormElement>) => void | Required | Callback when form is submitted |
appearance | 'default' | 'compact' | 'expandable' | 'default' | Visual appearance of the input |
placeholder | string (default/compact)string | string[] (expandable) | — | Placeholder text. Expandable supports array for rotation |
isLoading | boolean | false | Shows loading state, disables input |
disabled | boolean | false | Disables the input entirely |
label | string | — | Accessibility label for screen readers |
hideSendButton | boolean | false | Hides the send button |
themed | boolean | true | Enable theme-aware styling (light/dark modes) |
renderActions | () => ReactNode | — | Render function for action buttons (attachments, tools, etc.) |
renderAttachments | () => ReactNode | — | Render function for attachments section above input |
Typeahead Props (@ Mentions)
| Prop | Type | Default | Description |
|---|---|---|---|
typeaheadOptions | TypeaheadOptionData<T>[] | TypeaheadGroup<T>[] | — | Flat or grouped options for @ mention autocomplete |
onTypeaheadQueryChange | (query: string) => void | — | Callback when typeahead search query changes |
onTypeaheadSelect | (option: TypeaheadOptionData<T>) => void | — | Callback when typeahead option is selected |
getTypeaheadValueString | (value: T) => string | — | Required for objects: Convert typed value to string for display |
serializeTypeaheadValue | (value: T) => string | JSON.stringify | Serialize typed value for chip storage |
URL Detection Props
| Prop | Type | Default | Description |
|---|---|---|---|
onUrlPaste | (url: string) => void | — | Callback when valid URL is pasted. If provided, intercepts URL pastes |
Expandable-Specific Props
| Prop | Type | Default | Description |
|---|---|---|---|
placeholderInterval | number | 3000 | Milliseconds between placeholder rotations (when placeholder is array) |
isExpanded | boolean | — | Controlled: External expanded state |
onExpandedChange | (isExpanded: boolean) => void | — | Controlled: Callback when expanded state changes |
showAiThinker | boolean | false | Shows 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
| Prop | Type | Default | Description |
|---|---|---|---|
themed | boolean | true | Enable theme-aware styling |
children | ReactNode | Required | Content (typically AiChatInput, header, buttons) |
AiChatInputHeader
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | Required | Header text or content |
AiChatInputTextButton
| Prop | Type | Default | Description |
|---|---|---|---|
themed | boolean | true | Enable theme-aware styling |
children | ReactNode | Required | Button text |
onClick | () => void | — | Click handler |