godcrm/components/MentionInput.tsx
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

375 lines
12 KiB
TypeScript

/**
* MentionInput Component
* ADR-023: Agent-as-User & Infinite Chat Architecture
*
* A text input that supports @mentions with autocomplete dropdown
*/
import { useState, useRef, useEffect, KeyboardEvent, ChangeEvent } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/shared/utils/cn';
import { Bot, User } from 'lucide-react';
export interface MentionUser {
id: number;
name: string;
email?: string;
avatar?: string;
icon?: string;
type: 'human' | 'agent' | 'bot' | 'service';
}
// ADR-069: Trigger types - @ for mentions, / for agent commands
export type TriggerType = '@' | '/';
export interface MentionInputProps {
value: string;
onChange: (value: string) => void;
onMention?: (user: MentionUser, trigger: TriggerType) => void;
onSubmit?: () => void;
onPasteFiles?: (files: File[]) => void;
availableUsers: MentionUser[];
availableAgents?: MentionUser[]; // ADR-069: Separate list for /command agents
placeholder?: string;
disabled?: boolean;
className?: string;
inputClassName?: string;
maxRows?: number;
}
export function MentionInput({
value,
onChange,
onMention,
onSubmit,
onPasteFiles,
availableUsers,
availableAgents,
placeholder = 'Type @ to mention, / to call agent...',
disabled = false,
className,
inputClassName,
maxRows = 3
}: MentionInputProps) {
const [showDropdown, setShowDropdown] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 256 });
const [mentionQuery, setMentionQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const [mentionStartPos, setMentionStartPos] = useState(-1);
const [currentTrigger, setCurrentTrigger] = useState<TriggerType>('@'); // ADR-069
const inputRef = useRef<HTMLTextAreaElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Update dropdown position when shown
const updateDropdownPosition = () => {
if (inputRef.current) {
const rect = inputRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.top - 4, // Position above input
left: rect.left,
width: Math.max(rect.width, 256)
});
}
};
// ADR-069: Get list based on trigger type
// @ shows humans and bot users, / shows only agents
const sourceList = currentTrigger === '/'
? (availableAgents || [])
: availableUsers;
// Filter users based on mention query
const filteredUsers = sourceList.filter(user => {
if (!mentionQuery) return true;
const query = mentionQuery.toLowerCase();
return (
user.name.toLowerCase().includes(query) ||
user.email?.toLowerCase().includes(query)
);
});
// Auto-resize textarea
useEffect(() => {
const input = inputRef.current;
if (!input) return;
input.style.height = 'auto';
const lineHeight = 24;
const maxHeight = lineHeight * maxRows;
const newHeight = Math.min(input.scrollHeight, maxHeight);
input.style.height = `${newHeight}px`;
input.style.overflowY = input.scrollHeight > maxHeight ? 'auto' : 'hidden';
}, [value, maxRows]);
// Handle input change
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
const cursorPos = e.target.selectionStart || 0;
onChange(newValue);
const textBeforeCursor = newValue.substring(0, cursorPos);
// ADR-069: Check for both @ and / triggers
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
const lastSlashIndex = textBeforeCursor.lastIndexOf('/');
// Determine which trigger is active (the one closest to cursor)
let triggerIndex = -1;
let trigger: TriggerType = '@';
if (lastAtIndex > lastSlashIndex) {
triggerIndex = lastAtIndex;
trigger = '@';
} else if (lastSlashIndex > lastAtIndex) {
triggerIndex = lastSlashIndex;
trigger = '/';
}
if (triggerIndex !== -1) {
// Check if trigger is at start or after a space
const charBefore = textBeforeCursor[triggerIndex - 1];
if (triggerIndex === 0 || charBefore === ' ' || charBefore === '\n') {
const query = textBeforeCursor.substring(triggerIndex + 1);
// Only show dropdown if query doesn't contain space (still typing)
if (!query.includes(' ')) {
setMentionQuery(query);
setMentionStartPos(triggerIndex);
setCurrentTrigger(trigger);
setShowDropdown(true);
setSelectedIndex(0);
// Calculate dropdown position using RAF for accurate measurements
requestAnimationFrame(() => {
updateDropdownPosition();
});
return;
}
}
}
setShowDropdown(false);
setMentionStartPos(-1);
};
// Handle user selection from dropdown
const selectUser = (user: MentionUser) => {
if (mentionStartPos === -1) return;
const beforeMention = value.substring(0, mentionStartPos);
const afterMention = value.substring(mentionStartPos + 1 + mentionQuery.length);
// Create slug from name
const nameSlug = user.name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
// ADR-116: Wrap in structured invocation token <<@slug>> or <</slug>>
// This ensures only intentional invocations trigger agent delegation.
const token = `<<${currentTrigger}${nameSlug}>>`;
const newValue = `${beforeMention}${token} ${afterMention}`;
onChange(newValue);
onMention?.(user, currentTrigger);
setShowDropdown(false);
setMentionStartPos(-1);
// Focus back on input
setTimeout(() => {
inputRef.current?.focus();
// +4 for << and >> wrapper, +1 for trailing space
const newPos = beforeMention.length + token.length + 1;
inputRef.current?.setSelectionRange(newPos, newPos);
}, 0);
};
// Handle keyboard navigation
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
// Bug fix #81739: Allow native Ctrl/Cmd shortcuts (Ctrl+A, Ctrl+C, Ctrl+V, Ctrl+Z, etc.)
// But allow Ctrl+Enter to submit the message
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
onSubmit?.();
return;
}
if (e.ctrlKey || e.metaKey) return;
if (showDropdown && filteredUsers.length > 0) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev => (prev + 1) % filteredUsers.length);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => (prev - 1 + filteredUsers.length) % filteredUsers.length);
break;
case 'Enter':
e.preventDefault();
selectUser(filteredUsers[selectedIndex]);
break;
case 'Escape':
e.preventDefault();
setShowDropdown(false);
break;
case 'Tab':
e.preventDefault();
selectUser(filteredUsers[selectedIndex]);
break;
}
return;
}
// Submit on Enter without shift
if (e.key === 'Enter' && !e.shiftKey && !showDropdown) {
e.preventDefault();
onSubmit?.();
}
};
// Handle Ctrl+V paste — intercept files/images from clipboard
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData?.items;
if (!items) return;
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) files.push(file);
}
}
if (files.length > 0 && onPasteFiles) {
e.preventDefault();
onPasteFiles(files);
}
// If no files, let the default text paste happen
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node) &&
inputRef.current &&
!inputRef.current.contains(e.target as Node)
) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Scroll selected item into view
useEffect(() => {
if (showDropdown && dropdownRef.current) {
const selectedEl = dropdownRef.current.querySelector(`[data-index="${selectedIndex}"]`);
selectedEl?.scrollIntoView({ block: 'nearest' });
}
}, [selectedIndex, showDropdown]);
return (
<div ref={containerRef} className={cn('relative', className)}>
{/* No mirror overlay — plain text in textarea, pills only in sent messages (HighlightedText) */}
<textarea
ref={inputRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={placeholder}
disabled={disabled}
rows={1}
className={cn(
'w-full resize-none bg-transparent min-h-[24px]',
'placeholder:text-[var(--text-tertiary)] focus:outline-none',
'text-[var(--text-primary)]',
'text-sm whitespace-pre-wrap break-words',
inputClassName
)}
/>
{/* Mention Dropdown - Portal */}
{showDropdown && filteredUsers.length > 0 && createPortal(
<div
ref={dropdownRef}
className="fixed z-[9999] max-h-60 overflow-y-auto bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg shadow-xl"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
transform: 'translateY(-100%)'
}}
>
<div className="py-1">
{filteredUsers.map((user, index) => (
<button
key={user.id}
data-index={index}
onClick={() => selectUser(user)}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-left transition-colors',
index === selectedIndex
? 'bg-[var(--color-primary-500)]/15 text-[var(--color-primary-500)]'
: 'hover:bg-[var(--bg-tertiary)]'
)}
>
{/* User avatar/icon */}
<div className={cn(
'w-7 h-7 rounded-full flex items-center justify-center text-sm',
user.type === 'agent'
? 'bg-purple-500/20 text-purple-400'
: user.type === 'bot'
? 'bg-orange-500/20 text-orange-400'
: 'bg-blue-500/20 text-blue-400'
)}>
{user.icon || user.avatar ? (
user.icon || <img src={user.avatar} alt="" className="w-full h-full rounded-full" />
) : user.type === 'agent' ? (
<Bot className="w-4 h-4" />
) : user.type === 'bot' ? (
<Bot className="w-4 h-4" />
) : (
<User className="w-4 h-4" />
)}
</div>
{/* User info */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-[var(--text-primary)] truncate">
{user.name}
</div>
<div className="text-xs text-[var(--text-tertiary)] truncate">
{currentTrigger === '/' ? '⚡ Вызвать агента' : (user.type === 'agent' ? '🤖 AI Agent' : user.type === 'bot' ? '🤖 Agent User' : user.email || 'User')}
</div>
</div>
</button>
))}
</div>
</div>,
document.body
)}
{/* No results - Portal */}
{showDropdown && filteredUsers.length === 0 && mentionQuery && createPortal(
<div
className="fixed z-[9999] bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg shadow-xl p-3 text-center text-sm text-[var(--text-tertiary)]"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
transform: 'translateY(-100%)'
}}
>
No {currentTrigger === '/' ? 'agents' : 'users'} matching "{currentTrigger}{mentionQuery}"
</div>,
document.body
)}
</div>
);
}
export default MentionInput;