| 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; |
| }; |
|
|
| |
| const isMCPTool = (value: any): value is MCPTool => { |
| return value && typeof value === 'object' && typeof value.name === 'string'; |
| }; |
|
|
| |
| const ALLOWED_TOOLS = ['detect stance', 'generate argument', 'extract topic']; |
|
|
| |
| const isAllowedTool = (toolName: string): boolean => { |
| const toolLower = toolName.toLowerCase(); |
| return ALLOWED_TOOLS.some(allowed => |
| toolLower.includes(allowed.split(' ')[0]) && |
| toolLower.includes(allowed.split(' ')[1]) |
| ); |
| }; |
|
|
| |
| 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; |
| }; |
|
|
| 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(''); |
| 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(); |
| if (input.trim()) { |
| if (onSubmit) { |
| onSubmit(input, selectedTool, selectedStance || undefined); |
| } |
| console.log('Submitted:', input); |
| setInput(''); |
| |
| if (selectedTool && normalizeToolName(selectedTool) === 'generate argument') { |
| setSelectedStance(null); |
| } |
| } |
| return false; |
| }; |
|
|
| const handleMicClick = async () => { |
| try { |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| |
| |
| 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); |
| |
| if (audioRef.current) { |
| audioRef.current.pause(); |
| audioRef.current = null; |
| } |
| |
| stream.getTracks().forEach(track => track.stop()); |
| }; |
|
|
| mediaRecorder.start(); |
| setIsRecording(true); |
| setRecordingTime(0); |
|
|
| |
| 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') { |
| |
| const originalOnStop = mediaRecorderRef.current.onstop; |
| mediaRecorderRef.current.onstop = () => { |
| |
| if (originalOnStop) { |
| originalOnStop.call(mediaRecorderRef.current, new Event('stop')); |
| } |
|
|
| |
| const mimeType = mediaRecorderRef.current?.mimeType || 'audio/webm'; |
| const blob = new Blob(audioChunksRef.current, { type: mimeType }); |
|
|
| |
| 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; |
| } |
|
|
| |
| if (!audioRef.current) { |
| console.error('Audio element not initialized'); |
| return; |
| } |
|
|
| try { |
| if (isPlaying) { |
| audioRef.current.pause(); |
| setIsPlaying(false); |
| } else { |
| |
| if (audioRef.current.ended) { |
| audioRef.current.currentTime = 0; |
| } |
| |
| |
| 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); |
| |
| 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 = () => { |
| |
| if (audioRef.current && isPlaying) { |
| audioRef.current.pause(); |
| setIsPlaying(false); |
| } |
|
|
| |
| if (audioBlob && onAudioSubmit) { |
| onAudioSubmit(audioBlob, selectedTool, selectedStance || undefined); |
| |
| if (selectedTool && normalizeToolName(selectedTool) === 'generate argument') { |
| setSelectedStance(null); |
| } |
| } |
|
|
| |
| handleCancelRecording(); |
|
|
| |
| setTimeout(() => { |
| const textarea = document.querySelector('textarea'); |
| if (textarea) textarea.focus(); |
| }, 100); |
| }; |
|
|
| |
| useEffect(() => { |
| if (audioUrl) { |
| |
| if (audioRef.current && audioRef.current.src !== audioUrl) { |
| audioRef.current.pause(); |
| audioRef.current = null; |
| } |
|
|
| |
| 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 () => { |
| |
| }; |
| }, [audioUrl]); |
|
|
| |
| 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> |
| ); |
| }; |
|
|
| |
| 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); |
| }; |
| }, []); |
|
|
| |
| 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]); |
|
|
| |
| 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; |
| const minSpace = 20; |
|
|
| |
| if (spaceBelow < dropdownHeight + minSpace && spaceAbove > spaceBelow) { |
| setDropdownPosition('above'); |
| |
| const maxHeight = Math.min(450, spaceAbove - minSpace - 60); |
| setDropdownMaxHeight(Math.max(250, maxHeight)); |
| } else { |
| setDropdownPosition('below'); |
| |
| const maxHeight = Math.min(450, spaceBelow - minSpace - 60); |
| setDropdownMaxHeight(Math.max(250, maxHeight)); |
| } |
|
|
| |
| const dropdownWidth = 384; |
| const dropdownElement = dropdownRef.current?.querySelector('[data-dropdown-content]') as HTMLElement; |
| if (dropdownElement) { |
| if (buttonRect.left + dropdownWidth > viewportWidth - minSpace) { |
| |
| dropdownElement.style.right = '0'; |
| dropdownElement.style.left = 'auto'; |
| } else { |
| |
| dropdownElement.style.right = 'auto'; |
| dropdownElement.style.left = '0'; |
| } |
| } |
| }; |
|
|
| calculatePosition(); |
|
|
| |
| window.addEventListener('resize', calculatePosition); |
| window.addEventListener('scroll', calculatePosition, true); |
|
|
| return () => { |
| window.removeEventListener('resize', calculatePosition); |
| window.removeEventListener('scroll', calculatePosition, true); |
| }; |
| } |
| }, [showToolsDropdown]); |
|
|
| |
| 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"> |
| <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={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={!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; |
|
|
|
|