S01Nour
feat(chat): implement detect stance and extract topic tools with enhanced UI and message formatting
5a8c8b7
import React, { useState, useRef, useEffect } from 'react';
import { Plus, ArrowUp, Settings2, Mic, X, Check, Loader2, Search, Sparkles, Play, Pause } from 'lucide-react';
import { useMCPTools } from '../../hooks/useMCPTools.ts';
import type { MCPTool } from '../../types/index.ts';
type ChatInputProps = {
onSubmit?: (message: string, selectedTool?: string | null, stance?: 'positive' | 'negative') => void;
onAudioSubmit?: (audioBlob: Blob, selectedTool?: string | null, stance?: 'positive' | 'negative') => void;
placeholder?: string;
};
// Type guard to ensure tool type safety (used for runtime validation if needed)
const isMCPTool = (value: any): value is MCPTool => {
return value && typeof value === 'object' && typeof value.name === 'string';
};
// Allowed tools - only these 3 will be shown
const ALLOWED_TOOLS = ['detect stance', 'generate argument', 'extract topic'];
// Helper function to check if a tool name matches one of the allowed tools
const isAllowedTool = (toolName: string): boolean => {
const toolLower = toolName.toLowerCase();
return ALLOWED_TOOLS.some(allowed =>
toolLower.includes(allowed.split(' ')[0]) &&
toolLower.includes(allowed.split(' ')[1])
);
};
// Helper function to normalize tool name to standard format
const normalizeToolName = (toolName: string): string => {
const toolLower = toolName.toLowerCase();
if (toolLower.includes('detect') && toolLower.includes('stance')) {
return 'detect stance';
}
if (toolLower.includes('generate') && toolLower.includes('argument')) {
return 'generate argument';
}
if (toolLower.includes('extract') && toolLower.includes('topic')) {
return 'extract topic';
}
return toolName;
};
// Helper function to get dynamic placeholder based on selected tool
const getPlaceholder = (selectedTool: string | null, defaultPlaceholder: string): string => {
if (!selectedTool) {
return defaultPlaceholder;
}
const normalizedTool = normalizeToolName(selectedTool);
switch (normalizedTool) {
case 'detect stance':
return ''; // Will use separate fields, no placeholder needed
case 'generate argument':
return 'Enter a debate topic to generate an argument (e.g., "cannabis legalization")...';
case 'extract topic':
return 'Enter text to extract the topic (e.g., "Should we legalize assisted suicide?")...';
default:
return defaultPlaceholder;
}
};
const ChatInput = ({ onSubmit, onAudioSubmit, placeholder = 'Ask a follow-up...' }: ChatInputProps) => {
const [input, setInput] = useState('');
const [isRecording, setIsRecording] = useState(false);
const [showToolsDropdown, setShowToolsDropdown] = useState(false);
const [selectedTool, setSelectedTool] = useState<string | null>(null);
const [selectedStance, setSelectedStance] = useState<'positive' | 'negative' | null>(null);
const [searchQuery, setSearchQuery] = useState('');
// Separate inputs for detect stance tool
const [detectStanceTopic, setDetectStanceTopic] = useState('');
const [detectStanceArgument, setDetectStanceArgument] = useState('');
const [focusedIndex, setFocusedIndex] = useState(-1);
const [dropdownPosition, setDropdownPosition] = useState<'below' | 'above'>('below');
const [dropdownMaxHeight, setDropdownMaxHeight] = useState(320);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const dropdownRef = useRef<HTMLDivElement>(null);
const dropdownContentRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const audioRef = useRef<HTMLAudioElement | null>(null);
const recordingTimerRef = useRef<NodeJS.Timeout | null>(null);
const { tools, loading, error, refetch } = useMCPTools();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
const normalizedTool = selectedTool ? normalizeToolName(selectedTool) : null;
// Handle detect stance tool with two fields
if (normalizedTool === 'detect stance') {
if (detectStanceTopic.trim() && detectStanceArgument.trim()) {
// Format as JSON string for detect stance: topic and argument
const detectStanceInput = JSON.stringify({
topic: detectStanceTopic.trim(),
argument: detectStanceArgument.trim(),
});
if (onSubmit) {
onSubmit(detectStanceInput, selectedTool);
}
setDetectStanceTopic('');
setDetectStanceArgument('');
}
} else if (input.trim()) {
if (onSubmit) {
onSubmit(input, selectedTool, selectedStance || undefined);
}
console.log('Submitted:', input);
setInput('');
// Reset stance after submit if generate argument tool
if (normalizedTool === 'generate argument') {
setSelectedStance(null);
}
}
return false;
};
const handleMicClick = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Try to use a supported mime type
let options: MediaRecorderOptions = {};
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
options = { mimeType: 'audio/webm;codecs=opus' };
} else if (MediaRecorder.isTypeSupported('audio/webm')) {
options = { mimeType: 'audio/webm' };
} else if (MediaRecorder.isTypeSupported('audio/mp4')) {
options = { mimeType: 'audio/mp4' };
}
const mediaRecorder = new MediaRecorder(stream, options);
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
const mimeType = mediaRecorder.mimeType || 'audio/webm';
const blob = new Blob(audioChunksRef.current, { type: mimeType });
setAudioBlob(blob);
const url = URL.createObjectURL(blob);
setAudioUrl(url);
// Clean up old audio element
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
// Stop all tracks to release microphone
stream.getTracks().forEach(track => track.stop());
};
mediaRecorder.start();
setIsRecording(true);
setRecordingTime(0);
// Start recording timer
recordingTimerRef.current = setInterval(() => {
setRecordingTime(prev => prev + 1);
}, 1000);
} catch (error) {
console.error('Error accessing microphone:', error);
alert('Could not access microphone. Please check your permissions.');
}
};
const handleCancelRecording = () => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setIsRecording(false);
setIsPlaying(false);
setRecordingTime(0);
setAudioBlob(null);
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
setAudioUrl(null);
}
audioChunksRef.current = [];
};
const handleConfirmRecording = () => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
// Set up a temporary onstop handler specifically for submission
const originalOnStop = mediaRecorderRef.current.onstop;
mediaRecorderRef.current.onstop = () => {
// Call the original onStop to ensure all state is properly set
if (originalOnStop) {
originalOnStop.call(mediaRecorderRef.current, new Event('stop'));
}
// Create the blob directly from audioChunksRef to ensure we have the data
const mimeType = mediaRecorderRef.current?.mimeType || 'audio/webm';
const blob = new Blob(audioChunksRef.current, { type: mimeType });
// Submit the audio immediately
if (blob && onAudioSubmit) {
onAudioSubmit(blob, selectedTool);
} else {
console.error('Audio blob not available for submission', { blob: blob.size > 0, onAudioSubmit });
}
};
mediaRecorderRef.current.stop();
}
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
setIsRecording(false);
};
const handlePlayPause = async () => {
if (!audioUrl) {
console.error('Audio URL not available');
return;
}
// Ensure audio element exists
if (!audioRef.current) {
console.error('Audio element not initialized');
return;
}
try {
if (isPlaying) {
audioRef.current.pause();
setIsPlaying(false);
} else {
// Reset to beginning if needed
if (audioRef.current.ended) {
audioRef.current.currentTime = 0;
}
// Play the audio
const playPromise = audioRef.current.play();
if (playPromise !== undefined) {
await playPromise;
setIsPlaying(true);
} else {
setIsPlaying(true);
}
}
} catch (error) {
console.error('Error playing audio:', error);
setIsPlaying(false);
// Check if it's an autoplay policy issue
if (error instanceof Error && error.name === 'NotAllowedError') {
alert('Please interact with the page first, then try playing again.');
} else {
alert('Could not play audio. Please try again.');
}
}
};
const handleSendRecording = () => {
// Stop audio playback if playing
if (audioRef.current && isPlaying) {
audioRef.current.pause();
setIsPlaying(false);
}
// Submit the recorded audio if available
if (audioBlob && onAudioSubmit) {
onAudioSubmit(audioBlob, selectedTool, selectedStance || undefined);
// Reset stance after submit if generate argument tool
if (selectedTool && normalizeToolName(selectedTool) === 'generate argument') {
setSelectedStance(null);
}
}
// Clean up and return to normal chat mode
handleCancelRecording();
// Focus back to text input
setTimeout(() => {
const textarea = document.querySelector('textarea');
if (textarea) textarea.focus();
}, 100);
};
// Initialize audio element when audioUrl is available
useEffect(() => {
if (audioUrl) {
// Clean up old audio element if URL changed
if (audioRef.current && audioRef.current.src !== audioUrl) {
audioRef.current.pause();
audioRef.current = null;
}
// Create new audio element if it doesn't exist
if (!audioRef.current) {
const audio = new Audio(audioUrl);
audioRef.current = audio;
audio.volume = 1.0;
audio.onended = () => {
setIsPlaying(false);
};
audio.onerror = (error) => {
console.error('Audio initialization error:', error);
setIsPlaying(false);
};
audio.onloadeddata = () => {
console.log('Audio loaded and ready');
};
audio.oncanplay = () => {
console.log('Audio can play');
};
}
}
return () => {
// Don't clean up audio element here - let it persist for playback
};
}, [audioUrl]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
}
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
};
}, [audioUrl]);
const WaveAnimation = () => {
const [animationKey, setAnimationKey] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setAnimationKey((prev) => prev + 1);
}, 100);
return () => clearInterval(interval);
}, []);
const bars = Array.from({ length: 50 }, (_, i) => {
const height = Math.random() * 20 + 4;
const delay = Math.random() * 2;
return (
<div
key={`${i}-${animationKey}`}
className="bg-zinc-400 dark:bg-gray-400 rounded-sm animate-pulse"
style={{
width: '2px',
height: `${height}px`,
animationDelay: `${delay}s`,
animationDuration: '1s',
}}
/>
);
});
return (
<div className="flex items-center w-full gap-1">
<div className="flex-1 border-t-2 border-dotted border-zinc-400 dark:border-gray-500"></div>
<div className="flex items-center gap-0.5 justify-center px-8">{bars}</div>
<div className="flex-1 border-t-2 border-dotted border-zinc-400 dark:border-gray-500"></div>
</div>
);
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowToolsDropdown(false);
setFocusedIndex(-1);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!showToolsDropdown) return;
const filteredTools = tools.filter(isMCPTool).filter(tool =>
tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(tool.description && tool.description.toLowerCase().includes(searchQuery.toLowerCase()))
);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
setFocusedIndex(prev => (prev + 1) % filteredTools.length);
break;
case 'ArrowUp':
event.preventDefault();
setFocusedIndex(prev => prev <= 0 ? filteredTools.length - 1 : prev - 1);
break;
case 'Enter':
event.preventDefault();
if (focusedIndex >= 0 && filteredTools[focusedIndex]) {
setSelectedTool(filteredTools[focusedIndex].name);
setShowToolsDropdown(false);
setFocusedIndex(-1);
}
break;
case 'Escape':
event.preventDefault();
setShowToolsDropdown(false);
setFocusedIndex(-1);
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [showToolsDropdown, focusedIndex, tools, searchQuery]);
// Calculate dropdown position and max height based on viewport
useEffect(() => {
if (showToolsDropdown && dropdownRef.current) {
const calculatePosition = () => {
const buttonElement = dropdownRef.current?.querySelector('button');
if (!buttonElement) return;
const buttonRect = buttonElement.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const spaceBelow = viewportHeight - buttonRect.bottom;
const spaceAbove = buttonRect.top;
const dropdownHeight = 500; // Approximate max height (increased from 400)
const minSpace = 20; // Minimum space from viewport edge
// Determine if dropdown should be above or below
if (spaceBelow < dropdownHeight + minSpace && spaceAbove > spaceBelow) {
setDropdownPosition('above');
// Calculate max height based on available space above
const maxHeight = Math.min(450, spaceAbove - minSpace - 60); // Increased from 320 to 450
setDropdownMaxHeight(Math.max(250, maxHeight)); // Increased minimum from 200 to 250
} else {
setDropdownPosition('below');
// Calculate max height based on available space below
const maxHeight = Math.min(450, spaceBelow - minSpace - 60); // Increased from 320 to 450
setDropdownMaxHeight(Math.max(250, maxHeight)); // Increased minimum from 200 to 250
}
// Adjust horizontal position if dropdown would overflow
const dropdownWidth = 384; // w-96 = 384px (increased from w-80)
const dropdownElement = dropdownRef.current?.querySelector('[data-dropdown-content]') as HTMLElement;
if (dropdownElement) {
if (buttonRect.left + dropdownWidth > viewportWidth - minSpace) {
// Would overflow on the right, align to right edge
dropdownElement.style.right = '0';
dropdownElement.style.left = 'auto';
} else {
// Reset to left alignment
dropdownElement.style.right = 'auto';
dropdownElement.style.left = '0';
}
}
};
calculatePosition();
// Recalculate on window resize or scroll
window.addEventListener('resize', calculatePosition);
window.addEventListener('scroll', calculatePosition, true);
return () => {
window.removeEventListener('resize', calculatePosition);
window.removeEventListener('scroll', calculatePosition, true);
};
}
}, [showToolsDropdown]);
// Focus search input when dropdown opens
useEffect(() => {
if (showToolsDropdown && searchInputRef.current) {
setTimeout(() => searchInputRef.current?.focus(), 100);
}
}, [showToolsDropdown]);
const toggleToolsDropdown = () => {
const nextState = !showToolsDropdown;
setShowToolsDropdown(nextState);
setSearchQuery('');
setFocusedIndex(-1);
if (nextState) {
refetch();
}
};
return (
<div className="relative">
<form onSubmit={handleSubmit} className="relative">
<div
className="border border-zinc-300 dark:border-zinc-700 rounded-2xl p-4 relative transition-all duration-500 ease-in-out overflow-visible bg-zinc-100 dark:bg-[#141415]"
>
{isRecording ? (
<div className="flex items-center justify-between h-12 animate-fade-in w-full">
<div className="flex items-center gap-3 flex-1">
<WaveAnimation />
<span className="text-sm text-zinc-600 dark:text-zinc-400 whitespace-nowrap">
{Math.floor(recordingTime / 60)}:{(recordingTime % 60).toString().padStart(2, '0')}
</span>
</div>
<div className="flex items-center gap-2 ml-4">
<button
type="button"
onClick={handleCancelRecording}
className="h-8 w-8 p-0 text-zinc-800 dark:text-white hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-lg transition-all duration-200 hover:scale-110 flex items-center justify-center"
>
<X className="h-5 w-5" />
</button>
<button
type="button"
onClick={handleConfirmRecording}
className="h-8 w-8 p-0 rounded-lg transition-all duration-200 hover:scale-110 flex items-center justify-center bg-teal-400 dark:bg-[#2DD4BF] text-teal-900 dark:text-[#032827]"
>
<Check className="h-5 w-5" />
</button>
</div>
</div>
) : audioBlob && audioUrl ? (
<div className="flex items-center justify-between h-12 animate-fade-in w-full">
<div className="flex items-center gap-3 flex-1">
<button
type="button"
onClick={handlePlayPause}
className="h-8 w-8 p-0 text-zinc-800 dark:text-white hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-lg transition-all duration-200 hover:scale-110 flex items-center justify-center"
>
{isPlaying ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5" />
)}
</button>
<span className="text-sm text-zinc-600 dark:text-zinc-400">
{isPlaying ? 'Playing...' : 'Tap to replay'}
</span>
</div>
<div className="flex items-center gap-2 ml-4">
<button
type="button"
onClick={handleCancelRecording}
className="h-8 w-8 p-0 text-zinc-800 dark:text-white hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-lg transition-all duration-200 hover:scale-110 flex items-center justify-center"
>
<X className="h-5 w-5" />
</button>
<button
type="button"
onClick={handleSendRecording}
className="h-8 w-8 p-0 rounded-lg transition-all duration-200 hover:scale-110 flex items-center justify-center bg-teal-400 dark:bg-[#2DD4BF] text-teal-900 dark:text-[#032827]"
>
<ArrowUp className="h-5 w-5" />
</button>
</div>
</div>
) : (
<div className="animate-fade-in">
{/* Two input fields for detect stance tool */}
{selectedTool && normalizeToolName(selectedTool) === 'detect stance' ? (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-zinc-600 dark:text-zinc-400 mb-1.5">
Topic
</label>
<input
type="text"
value={detectStanceTopic}
onChange={(e) => setDetectStanceTopic(e.target.value)}
placeholder="Enter the debate topic (e.g., Climate change is real)"
className="w-full bg-transparent text-zinc-800 dark:text-gray-300 placeholder-zinc-400 dark:placeholder-gray-500 border border-zinc-300 dark:border-zinc-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 dark:focus:ring-teal-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-600 dark:text-zinc-400 mb-1.5">
Argument
</label>
<textarea
value={detectStanceArgument}
onChange={(e) => setDetectStanceArgument(e.target.value)}
placeholder="Enter the argument to analyze (e.g., Rising global temperatures prove it)"
className="w-full bg-transparent text-zinc-800 dark:text-gray-300 placeholder-zinc-400 dark:placeholder-gray-500 border border-zinc-300 dark:border-zinc-600 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-teal-500 dark:focus:ring-teal-400 min-h-[60px]"
rows={2}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && detectStanceTopic.trim() && detectStanceArgument.trim()) {
e.preventDefault();
handleSubmit(e as any);
}
}}
/>
</div>
</div>
) : (
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (input.trim()) {
if (onSubmit) {
onSubmit(input, selectedTool, selectedStance || undefined);
}
setInput('');
// Reset stance after submit if generate argument tool
if (selectedTool && normalizeToolName(selectedTool) === 'generate argument') {
setSelectedStance(null);
}
}
}
}}
placeholder={getPlaceholder(selectedTool, placeholder)}
className="w-full bg-transparent text-zinc-800 dark:text-gray-300 placeholder-zinc-400 dark:placeholder-gray-500 resize-none border-none outline-none text-base leading-relaxed min-h-[24px] max-h-32 transition-all duration-200"
rows={1}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = 'auto';
target.style.height = target.scrollHeight + 'px';
}}
/>
)}
{/* Stance selection buttons for generate argument tool */}
{selectedTool && normalizeToolName(selectedTool) === 'generate argument' && (
<div className="mt-3 flex items-center gap-2">
<span className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Select stance:</span>
<button
type="button"
onClick={() => setSelectedStance(selectedStance === 'positive' ? null : 'positive')}
className={`px-3 py-1.5 text-xs rounded-full font-medium transition-all ${
selectedStance === 'positive'
? 'bg-emerald-500 text-white shadow-md'
: 'bg-emerald-100 text-emerald-800 hover:bg-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-200 dark:hover:bg-emerald-900/60'
}`}
>
Positive
</button>
<button
type="button"
onClick={() => setSelectedStance(selectedStance === 'negative' ? null : 'negative')}
className={`px-3 py-1.5 text-xs rounded-full font-medium transition-all ${
selectedStance === 'negative'
? 'bg-rose-500 text-white shadow-md'
: 'bg-rose-100 text-rose-800 hover:bg-rose-200 dark:bg-rose-900/40 dark:text-rose-200 dark:hover:bg-rose-900/60'
}`}
>
Negative
</button>
</div>
)}
<div className="flex items-center justify-between mt-8">
<div className="flex items-center gap-2">
<button
type="button"
className="h-8 w-8 p-0 text-zinc-800 dark:text-white hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-lg transition-all duration-200 hover:scale-110 flex items-center justify-center"
>
<Plus className="h-5 w-5" />
</button>
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={toggleToolsDropdown}
className={`h-8 w-8 p-0 rounded-lg transition-all duration-200 hover:scale-110 flex items-center justify-center relative ${selectedTool ? 'bg-teal-500/15 text-teal-600 dark:bg-teal-500/20 dark:text-teal-300 hover:bg-teal-500/25 dark:hover:bg-teal-500/30' : 'text-zinc-800 dark:text-white hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-200 dark:hover:bg-zinc-700'}`}
aria-expanded={showToolsDropdown}
aria-haspopup="true"
>
<Settings2 className="h-5 w-5" />
{selectedTool && (
<div className="absolute -top-1 -right-1 h-2 w-2 bg-teal-500 rounded-full animate-pulse"></div>
)}
</button>
{showToolsDropdown && (
<div
ref={dropdownContentRef}
data-dropdown-content
className={`absolute left-0 w-96 rounded-2xl border border-zinc-200/70 dark:border-zinc-700/60 bg-white/95 dark:bg-zinc-900/95 backdrop-blur-xl shadow-[0_20px_45px_-20px_rgba(12,12,12,0.75)] z-50 overflow-hidden animate-fade-in flex flex-col ${
dropdownPosition === 'above'
? 'bottom-[calc(100%+0.6rem)]'
: 'top-[calc(100%+0.6rem)]'
}`}
style={{ maxHeight: `${dropdownMaxHeight}px` }}
>
{/* Header */}
<div className="px-4 py-3 border-b border-zinc-200/50 dark:border-zinc-700/50 flex-shrink-0">
<div className="flex items-center gap-2 mb-3">
<Sparkles className="h-4 w-4 text-teal-500 dark:text-teal-400" />
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Available Tools</h3>
<div className="ml-auto">
<span className="text-xs text-zinc-500 dark:text-zinc-400 bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded-full">
{tools.filter(isMCPTool).length} tools
</span>
</div>
</div>
{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-zinc-400 dark:text-zinc-500" />
<input
ref={searchInputRef}
type="text"
placeholder="Search tools..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setFocusedIndex(-1);
}}
className="w-full pl-10 pr-4 py-2 text-sm bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-900 dark:text-zinc-100 placeholder-zinc-500 dark:placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-teal-500/50 focus:border-teal-500/50 transition-all duration-200"
/>
</div>
</div>
{/* Tools list */}
<div
className="overflow-y-auto scrollbar-hide flex-1"
style={{ maxHeight: `${dropdownMaxHeight - 140}px` }}
>
{loading ? (
<div className="flex items-center justify-center gap-3 px-4 py-8">
<Loader2 className="h-5 w-5 animate-spin text-teal-500 dark:text-teal-400" />
<span className="text-sm text-zinc-600 dark:text-zinc-300">Loading tools…</span>
</div>
) : error ? (
<div className="px-4 py-6">
<div className="flex flex-col items-center gap-3 text-center">
<div className="h-12 w-12 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
<X className="h-6 w-6 text-red-500 dark:text-red-400" />
</div>
<div>
<p className="text-sm font-medium text-red-600 dark:text-red-400">Failed to load tools</p>
<p className="text-xs text-red-500 dark:text-red-500 mt-1">Please try again later</p>
</div>
</div>
</div>
) : tools.filter(isMCPTool).filter(tool => isAllowedTool(tool.name)).length === 0 ? (
<div className="px-4 py-6">
<div className="flex flex-col items-center gap-3 text-center">
<div className="h-12 w-12 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
<Settings2 className="h-6 w-6 text-zinc-400 dark:text-zinc-500" />
</div>
<div>
<p className="text-sm font-medium text-zinc-600 dark:text-zinc-300">No tools available</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">Check back later for updates</p>
</div>
</div>
</div>
) : (
<div className="p-2">
{tools
.filter(isMCPTool)
.filter(tool => isAllowedTool(tool.name))
.filter(tool =>
tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(tool.description && tool.description.toLowerCase().includes(searchQuery.toLowerCase()))
)
.map((tool, index) => (
<button
key={tool.name || index}
type="button"
className={`w-full rounded-xl px-4 py-3 text-left transition-all duration-200 group ${
focusedIndex === index
? 'bg-teal-50 dark:bg-teal-900/20 border border-teal-300/80 dark:border-teal-400/60'
: selectedTool === tool.name
? 'bg-teal-50/50 dark:bg-teal-900/10 border border-teal-200/60 dark:border-teal-400/40'
: 'hover:bg-zinc-50 dark:hover:bg-zinc-800/50 border border-transparent'
}`}
onClick={() => {
const newTool = tool.name === selectedTool ? null : tool.name;
setSelectedTool(newTool);
// Reset stance when tool changes
if (newTool && normalizeToolName(newTool) !== 'generate argument') {
setSelectedStance(null);
}
setShowToolsDropdown(false);
setFocusedIndex(-1);
}}
onMouseEnter={() => setFocusedIndex(index)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 truncate">
{tool.name}
</h4>
{selectedTool === tool.name && (
<Check className="h-3.5 w-3.5 text-teal-500 dark:text-teal-400 flex-shrink-0" />
)}
</div>
{tool.description && (
<p className="text-xs text-zinc-600 dark:text-zinc-400 line-clamp-2 leading-relaxed">
{tool.description}
</p>
)}
</div>
</div>
</button>
))}
</div>
)}
</div>
{/* Footer */}
<div className="px-4 py-2 border-t border-zinc-200/50 dark:border-zinc-700/50 bg-zinc-50/50 dark:bg-zinc-800/30 flex-shrink-0">
<div className="flex items-center justify-between">
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Use ↑↓ to navigate, Enter to select
</p>
<button
type="button"
onClick={() => {
setSelectedTool(null);
setSelectedStance(null);
setShowToolsDropdown(false);
setFocusedIndex(-1);
}}
className="text-xs text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors duration-200"
>
Clear selection
</button>
</div>
</div>
</div>
)}
</div>
<button
type="button"
onClick={handleMicClick}
className="h-8 w-8 p-0 text-zinc-800 dark:text-white hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95 active:bg-red-600/20 active:text-red-400 flex items-center justify-center"
>
<Mic className="h-5 w-5 transition-transform duration-200" />
</button>
<button
type="button"
className="h-8 px-3 rounded-lg text-sm font-medium hover:opacity-90 transition-all duration-200 hover:scale-105 flex items-center justify-center bg-teal-900 dark:bg-[#032827] text-teal-300 dark:text-[#2DD4BF] disabled:opacity-50 disabled:cursor-not-allowed"
>
LLama 4
</button>
</div>
<button
type="submit"
disabled={
selectedTool && normalizeToolName(selectedTool) === 'detect stance'
? !detectStanceTopic.trim() || !detectStanceArgument.trim()
: !input.trim()
}
className="h-8 w-8 p-0 bg-zinc-300 dark:bg-zinc-700 hover:bg-zinc-400 dark:hover:bg-zinc-600 disabled:bg-zinc-200 dark:disabled:bg-zinc-800 disabled:text-zinc-400 dark:disabled:text-zinc-500 text-zinc-800 dark:text-white rounded-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100 flex items-center justify-center disabled:cursor-not-allowed"
>
<ArrowUp className="h-5 w-5" />
</button>
</div>
</div>
)}
</div>
</form>
</div>
);
};
export default ChatInput;