Spaces:
Sleeping
Sleeping
| 'use client' | |
| import Image from 'next/image' | |
| import { useRef, useEffect, useState, useCallback } from 'react' | |
| import { useMissionControl, type ChatAttachment } from '@/store' | |
| import { Button } from '@/components/ui/button' | |
| interface ChatInputProps { | |
| onSend: (content: string, attachments?: ChatAttachment[]) => void | |
| onAbort?: () => void | |
| disabled?: boolean | |
| agents?: Array<{ name: string; role: string }> | |
| isGenerating?: boolean | |
| } | |
| export function ChatInput({ onSend, onAbort, disabled, agents = [], isGenerating }: ChatInputProps) { | |
| const { chatInput, setChatInput, isSendingMessage } = useMissionControl() | |
| const textareaRef = useRef<HTMLTextAreaElement>(null) | |
| const fileInputRef = useRef<HTMLInputElement>(null) | |
| const [showMentions, setShowMentions] = useState(false) | |
| const [mentionFilter, setMentionFilter] = useState('') | |
| const [mentionIndex, setMentionIndex] = useState(0) | |
| const [attachments, setAttachments] = useState<ChatAttachment[]>([]) | |
| const [isDragOver, setIsDragOver] = useState(false) | |
| const filteredAgents = agents.filter(a => | |
| a.name.toLowerCase().includes(mentionFilter.toLowerCase()) | |
| ) | |
| const autoResize = useCallback(() => { | |
| const textarea = textareaRef.current | |
| if (textarea) { | |
| textarea.style.height = 'auto' | |
| textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px' | |
| } | |
| }, []) | |
| useEffect(() => { | |
| autoResize() | |
| }, [chatInput, autoResize]) | |
| // Focus textarea when panel opens | |
| useEffect(() => { | |
| if (!disabled) { | |
| textareaRef.current?.focus() | |
| } | |
| }, [disabled]) | |
| const addFiles = useCallback((files: FileList | File[]) => { | |
| const fileArray = Array.from(files) | |
| for (const file of fileArray) { | |
| if (file.size > 10 * 1024 * 1024) continue // Skip files > 10MB | |
| const reader = new FileReader() | |
| reader.onload = () => { | |
| const dataUrl = reader.result as string | |
| setAttachments(prev => [...prev, { | |
| name: file.name, | |
| type: file.type, | |
| size: file.size, | |
| dataUrl, | |
| }]) | |
| } | |
| reader.readAsDataURL(file) | |
| } | |
| }, []) | |
| const removeAttachment = useCallback((index: number) => { | |
| setAttachments(prev => prev.filter((_, i) => i !== index)) | |
| }, []) | |
| const handleDragOver = useCallback((e: React.DragEvent) => { | |
| e.preventDefault() | |
| setIsDragOver(true) | |
| }, []) | |
| const handleDragLeave = useCallback((e: React.DragEvent) => { | |
| e.preventDefault() | |
| setIsDragOver(false) | |
| }, []) | |
| const handleDrop = useCallback((e: React.DragEvent) => { | |
| e.preventDefault() | |
| setIsDragOver(false) | |
| if (e.dataTransfer.files.length > 0) { | |
| addFiles(e.dataTransfer.files) | |
| } | |
| }, [addFiles]) | |
| const handlePaste = useCallback((e: React.ClipboardEvent) => { | |
| const items = e.clipboardData?.items | |
| if (!items) return | |
| const imageItems: File[] = [] | |
| for (let i = 0; i < items.length; i++) { | |
| if (items[i].type.startsWith('image/')) { | |
| const file = items[i].getAsFile() | |
| if (file) imageItems.push(file) | |
| } | |
| } | |
| if (imageItems.length > 0) { | |
| e.preventDefault() | |
| addFiles(imageItems) | |
| } | |
| }, [addFiles]) | |
| const handleKeyDown = (e: React.KeyboardEvent) => { | |
| if (showMentions) { | |
| if (e.key === 'ArrowDown') { | |
| e.preventDefault() | |
| setMentionIndex(i => Math.min(i + 1, filteredAgents.length - 1)) | |
| return | |
| } | |
| if (e.key === 'ArrowUp') { | |
| e.preventDefault() | |
| setMentionIndex(i => Math.max(i - 1, 0)) | |
| return | |
| } | |
| if (e.key === 'Enter' || e.key === 'Tab') { | |
| e.preventDefault() | |
| if (filteredAgents[mentionIndex]) { | |
| insertMention(filteredAgents[mentionIndex].name) | |
| } | |
| return | |
| } | |
| if (e.key === 'Escape') { | |
| setShowMentions(false) | |
| return | |
| } | |
| } | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault() | |
| handleSend() | |
| } | |
| } | |
| const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { | |
| const value = e.target.value | |
| setChatInput(value) | |
| const cursorPos = e.target.selectionStart | |
| const textBeforeCursor = value.slice(0, cursorPos) | |
| const atMatch = textBeforeCursor.match(/@(\w*)$/) | |
| if (atMatch) { | |
| setMentionFilter(atMatch[1]) | |
| setShowMentions(true) | |
| setMentionIndex(0) | |
| } else { | |
| setShowMentions(false) | |
| } | |
| } | |
| const insertMention = (agentName: string) => { | |
| const textarea = textareaRef.current | |
| if (!textarea) return | |
| const cursorPos = textarea.selectionStart | |
| const textBeforeCursor = chatInput.slice(0, cursorPos) | |
| const textAfterCursor = chatInput.slice(cursorPos) | |
| const atIndex = textBeforeCursor.lastIndexOf('@') | |
| const newText = textBeforeCursor.slice(0, atIndex) + `@${agentName} ` + textAfterCursor | |
| setChatInput(newText) | |
| setShowMentions(false) | |
| setTimeout(() => { | |
| const newPos = atIndex + agentName.length + 2 | |
| textarea.setSelectionRange(newPos, newPos) | |
| textarea.focus() | |
| }, 0) | |
| } | |
| const handleSend = () => { | |
| const trimmed = chatInput.trim() | |
| if ((!trimmed && attachments.length === 0) || disabled || isSendingMessage) return | |
| onSend(trimmed, attachments.length > 0 ? attachments : undefined) | |
| setChatInput('') | |
| setAttachments([]) | |
| if (textareaRef.current) { | |
| textareaRef.current.style.height = 'auto' | |
| } | |
| } | |
| const formatFileSize = (bytes: number) => { | |
| if (bytes < 1024) return `${bytes} B` | |
| if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` | |
| return `${(bytes / (1024 * 1024)).toFixed(1)} MB` | |
| } | |
| return ( | |
| <div | |
| className={`relative border-t border-border bg-card/80 backdrop-blur-sm p-3 flex-shrink-0 safe-area-bottom ${isDragOver ? 'ring-2 ring-primary/50 bg-primary/5' : ''}`} | |
| onDragOver={handleDragOver} | |
| onDragLeave={handleDragLeave} | |
| onDrop={handleDrop} | |
| > | |
| {/* Mention autocomplete dropdown */} | |
| {showMentions && filteredAgents.length > 0 && ( | |
| <div className="absolute bottom-full left-3 right-3 mb-1 bg-popover/95 backdrop-blur-lg border border-border rounded-lg shadow-xl overflow-hidden max-h-40 overflow-y-auto z-10"> | |
| {filteredAgents.map((agent, i) => ( | |
| <Button | |
| key={agent.name} | |
| variant="ghost" | |
| size="sm" | |
| className={`w-full justify-start px-3 py-2 h-auto text-sm gap-2 rounded-none ${ | |
| i === mentionIndex ? 'bg-accent text-accent-foreground' : '' | |
| }`} | |
| onMouseDown={(e) => { | |
| e.preventDefault() | |
| insertMention(agent.name) | |
| }} | |
| > | |
| <div className="w-5 h-5 rounded-full bg-surface-2 flex items-center justify-center text-[9px] font-bold text-muted-foreground"> | |
| {agent.name.charAt(0).toUpperCase()} | |
| </div> | |
| <span className="font-medium text-foreground">@{agent.name}</span> | |
| <span className="text-muted-foreground text-xs ml-auto">{agent.role}</span> | |
| </Button> | |
| ))} | |
| </div> | |
| )} | |
| {/* Attachment previews */} | |
| {attachments.length > 0 && ( | |
| <div className="flex flex-wrap gap-2 mb-2"> | |
| {attachments.map((att, idx) => ( | |
| <div key={idx} className="relative group rounded-md border border-border/60 bg-surface-1 overflow-hidden"> | |
| {att.type.startsWith('image/') ? ( | |
| <Image | |
| src={att.dataUrl} | |
| alt={att.name} | |
| width={64} | |
| height={64} | |
| unoptimized | |
| className="h-16 w-16 object-cover" | |
| /> | |
| ) : ( | |
| <div className="h-16 w-16 flex flex-col items-center justify-center px-1"> | |
| <span className="text-lg">F</span> | |
| <span className="text-[9px] text-muted-foreground truncate w-full text-center">{att.name}</span> | |
| </div> | |
| )} | |
| <div className="absolute bottom-0 left-0 right-0 bg-black/60 text-[9px] text-white/80 px-1 py-0.5 truncate"> | |
| {formatFileSize(att.size)} | |
| </div> | |
| <button | |
| onClick={() => removeAttachment(idx)} | |
| className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-black/60 text-white/80 text-[10px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500/80" | |
| > | |
| x | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {/* Drag overlay hint */} | |
| {isDragOver && ( | |
| <div className="absolute inset-0 flex items-center justify-center bg-primary/10 border-2 border-dashed border-primary/40 rounded-lg z-20 pointer-events-none"> | |
| <span className="text-sm text-primary font-medium">Drop files here</span> | |
| </div> | |
| )} | |
| <div className="flex items-end gap-2"> | |
| {/* Attach button */} | |
| <Button | |
| onClick={() => fileInputRef.current?.click()} | |
| disabled={disabled || isSendingMessage} | |
| variant="ghost" | |
| size="icon-sm" | |
| className="rounded-lg flex-shrink-0" | |
| title="Attach file" | |
| > | |
| <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="M13.5 7.5l-5.8 5.8a3.2 3.2 0 01-4.5-4.5l5.8-5.8a2.1 2.1 0 013 3l-5.8 5.7a1 1 0 01-1.4-1.4l5.1-5.2" /> | |
| </svg> | |
| </Button> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| multiple | |
| className="hidden" | |
| onChange={(e) => { | |
| if (e.target.files) addFiles(e.target.files) | |
| e.target.value = '' | |
| }} | |
| /> | |
| <textarea | |
| ref={textareaRef} | |
| value={chatInput} | |
| onChange={handleChange} | |
| onKeyDown={handleKeyDown} | |
| onPaste={handlePaste} | |
| placeholder={disabled ? 'Select a conversation...' : 'Message... (@ to mention, Enter to send)'} | |
| disabled={disabled || isSendingMessage} | |
| rows={1} | |
| className="flex-1 resize-none bg-surface-1 rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50 disabled:opacity-40 transition-all" | |
| /> | |
| {/* Stop / Send button */} | |
| {isGenerating && onAbort ? ( | |
| <Button | |
| onClick={onAbort} | |
| variant="ghost" | |
| size="icon-sm" | |
| className="rounded-lg flex-shrink-0 text-red-400 hover:text-red-300 hover:bg-red-500/10" | |
| title="Stop generation" | |
| > | |
| <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"> | |
| <rect x="3" y="3" width="10" height="10" rx="1.5" /> | |
| </svg> | |
| </Button> | |
| ) : ( | |
| <Button | |
| onClick={handleSend} | |
| disabled={(!chatInput.trim() && attachments.length === 0) || disabled || isSendingMessage} | |
| size="icon-sm" | |
| className="rounded-lg flex-shrink-0" | |
| title="Send message" | |
| > | |
| {isSendingMessage ? ( | |
| <span className="inline-block w-3.5 h-3.5 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" /> | |
| ) : ( | |
| <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="M14 2L7 9" /> | |
| <path d="M14 2l-5 12-2-5-5-2 12-5z" /> | |
| </svg> | |
| )} | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| } | |