/**
* ChatInput Component
*
* Message input field with:
* - Auto-expanding textarea
* - Character count
* - Send button with loading state
* - Keyboard shortcuts (Enter to send, Shift+Enter for newline)
* - Disabled state during loading
*/
'use client';
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Send, Loader2, Mic, MicOff } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useLocale } from 'next-intl';
import { useChatInput } from '@/lib/stores/chatStore';
import { useAuth } from '@/contexts/AuthContext';
import { useSpeechRecognition } from '@/components/voice/voice-system';
import { MentionAutocomplete, type MentionSuggestion } from '@/components/mentions';
const MAX_LENGTH = 2000;
export function ChatInput() {
const { input, setInput, sendMessage, isLoading } = useChatInput();
const { user } = useAuth();
const router = useRouter();
const locale = useLocale();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Mention autocomplete state
const [showMentionAutocomplete, setShowMentionAutocomplete] = useState(false);
const [mentionQuery, setMentionQuery] = useState('');
const [mentionStartIndex, setMentionStartIndex] = useState(0);
const [autocompletePosition, setAutocompletePosition] = useState({ top: 0, left: 0 });
// Voice recognition
const {
isListening,
transcript,
interimTranscript,
error: voiceError,
startListening,
stopListening,
isSupported: isVoiceSupported,
} = useSpeechRecognition();
// Update input when transcript changes
useEffect(() => {
if (transcript) {
setInput(transcript.trim());
}
}, [transcript, setInput]);
// Calculate autocomplete position based on caret
const calculateAutocompletePosition = useCallback(() => {
if (!textareaRef.current || !containerRef.current) return { top: 0, left: 0 };
// Position above the textarea
const containerRect = containerRef.current.getBoundingClientRect();
return {
top: -320, // Position above input (max-h-96 = 384px, give some buffer)
left: 0,
};
}, []);
// Handle mention autocomplete trigger
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
const cursorPos = e.target.selectionStart || 0;
setInput(newValue);
// Check for @ trigger
// Look backwards from cursor to find @ that starts a mention
let atIndex = -1;
for (let i = cursorPos - 1; i >= 0; i--) {
const char = newValue[i];
// Stop if we hit whitespace or start of string - check if we passed an @
if (char === ' ' || char === '\n' || char === '\t') {
break;
}
if (char === '@') {
atIndex = i;
break;
}
}
if (atIndex >= 0) {
// Extract query after @
const query = newValue.slice(atIndex + 1, cursorPos);
// Only show autocomplete if query is valid (no spaces, reasonable length)
if (query.length <= 50 && !query.includes(' ')) {
setMentionQuery(query);
setMentionStartIndex(atIndex);
setAutocompletePosition(calculateAutocompletePosition());
setShowMentionAutocomplete(true);
} else {
setShowMentionAutocomplete(false);
}
} else {
setShowMentionAutocomplete(false);
}
}, [setInput, calculateAutocompletePosition]);
// Handle mention selection
const handleMentionSelect = useCallback((suggestion: MentionSuggestion) => {
// Calculate the end index (current cursor should be at end of query)
const endIndex = mentionStartIndex + 1 + mentionQuery.length; // +1 for @
// Replace @query with the mention string + space
const before = input.slice(0, mentionStartIndex);
const after = input.slice(endIndex);
const newInput = before + suggestion.mentionString + ' ' + after;
setInput(newInput);
setShowMentionAutocomplete(false);
setMentionQuery('');
// Focus textarea and move cursor after the inserted mention
if (textareaRef.current) {
textareaRef.current.focus();
const newCursorPos = mentionStartIndex + suggestion.mentionString.length + 1;
setTimeout(() => {
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
}
}, [input, setInput, mentionStartIndex, mentionQuery]);
// Handle mention cancellation
const handleMentionCancel = useCallback(() => {
setShowMentionAutocomplete(false);
setMentionQuery('');
textareaRef.current?.focus();
}, []);
// Auto-resize textarea based on content
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
}
}, [input]);
// Handle voice toggle
const handleVoiceToggle = () => {
if (isListening) {
stopListening();
} else {
startListening();
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
console.log('[ChatInput] handleSubmit called', { input, isLoading, user });
// Block anonymous users from sending messages
if (!user) {
console.log('[ChatInput] Blocked - user not authenticated');
// Show alert and redirect to signup
if (window.confirm('Please sign up to chat with Gordie. Click OK to create a free account.')) {
router.push('/auth/signup');
}
return;
}
if (!input.trim() || isLoading) {
console.log('[ChatInput] Submit blocked - empty input or loading');
return;
}
console.log('[ChatInput] Sending message:', input);
await sendMessage(input);
// Reset textarea height after sending
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Send on Enter (without Shift)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
const remainingChars = MAX_LENGTH - input.length;
const isNearLimit = remainingChars < 100;
return (
<form onSubmit={handleSubmit} className="px-4 py-3 border-t border-gray-700 bg-gray-900" role="search" aria-label="Send a message to Gordie">
{/* Full-width textarea container with buttons inside */}
<div ref={containerRef} className="relative">
{/* Terminal prompt */}
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-white font-mono pointer-events-none flex items-center z-10">
<span className="opacity-80">></span>
</div>
{/* Mention Autocomplete */}
{showMentionAutocomplete && (
<MentionAutocomplete
query={mentionQuery}
isVisible={showMentionAutocomplete}
position={autocompletePosition}
onSelect={handleMentionSelect}
onCancel={handleMentionCancel}
locale={locale}
maxSuggestions={6}
/>
)}
<textarea
ref={textareaRef}
value={input + (interimTranscript ? ' ' + interimTranscript : '')}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={isListening ? 'Listening...' : 'Ask about Parliament, MPs, bills... (type @ to mention)'}
disabled={isLoading || isListening}
maxLength={MAX_LENGTH}
rows={1}
aria-label="Message input"
aria-invalid={remainingChars < 0}
className={`w-full resize-none rounded-lg pl-10 py-3 pr-24 text-sm focus:outline-none focus:ring-1 focus:ring-accent-red font-mono bg-gray-800 border-0 caret-white placeholder-gray-500 overflow-hidden ${
isLoading
? 'cursor-not-allowed opacity-60 text-gray-400'
: isListening
? 'border-2 border-accent-red/50 text-white'
: 'text-white'
}`}
style={{
minHeight: '48px',
maxHeight: '120px',
}}
/>
{/* Character count (only show when near limit) */}
{isNearLimit && (
<div
role="status"
aria-live="polite"
aria-label={`${remainingChars} characters remaining`}
className={`absolute bottom-2 right-24 text-xs ${
remainingChars < 0
? 'text-red-600 font-semibold'
: remainingChars < 50
? 'text-orange-600'
: 'text-text-tertiary'
}`}
>
{remainingChars}
</div>
)}
{/* Buttons inside textarea container */}
<div className="absolute bottom-1.5 right-2 flex gap-1.5">
{/* Voice Input Button */}
{isVoiceSupported && (
<button
type="button"
onClick={handleVoiceToggle}
disabled={isLoading}
className={`w-9 h-9 rounded-full flex items-center justify-center transition-all ${
isListening
? 'bg-accent-red text-white animate-pulse'
: isLoading
? 'bg-gray-700 text-gray-500 cursor-not-allowed'
: 'bg-gray-700 text-gray-400 hover:bg-gray-600 hover:text-white'
}`}
title={isListening ? 'Stop voice input' : 'Start voice input'}
aria-label={isListening ? 'Stop voice input' : 'Start voice input'}
aria-pressed={isListening}
>
{isListening ? (
<MicOff className="w-4 h-4" aria-hidden="true" />
) : (
<Mic className="w-4 h-4" aria-hidden="true" />
)}
</button>
)}
{/* Send Button */}
<button
type="submit"
disabled={!input.trim() || isLoading || remainingChars < 0}
className={`w-9 h-9 rounded-full flex items-center justify-center transition-colors ${
!input.trim() || isLoading || remainingChars < 0
? 'bg-gray-700 text-gray-500 cursor-not-allowed'
: 'bg-accent-red text-white hover:bg-red-700 active:scale-95'
}`}
title={isLoading ? 'Sending...' : 'Send message (Enter)'}
aria-label={isLoading ? 'Sending message' : 'Send message'}
aria-busy={isLoading}
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />
) : (
<Send className="w-4 h-4" aria-hidden="true" />
)}
</button>
</div>
</div>
{/* Voice error message */}
{voiceError && (
<div className="mt-2 text-xs text-red-400 flex items-center gap-1">
<span>{voiceError}</span>
</div>
)}
{/* Voice listening indicator */}
{isListening && (
<div className="mt-2 text-xs text-accent-red flex items-center gap-2 animate-pulse">
<span className="w-2 h-2 bg-accent-red rounded-full animate-ping" />
<span>Listening... (tap mic or speak to stop)</span>
</div>
)}
</form>
);
}