import React, { useState, useRef, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Send, Maximize2, Minimize2, ChevronUp, Sparkles, Zap } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Popover } from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; import { FilePicker } from "./FilePicker"; import { ImagePreview } from "./ImagePreview"; import { type FileEntry } from "@/lib/api"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; interface FloatingPromptInputProps { /** * Callback when prompt is sent */ onSend: (prompt: string, model: "sonnet" | "opus") => void; /** * Whether the input is loading */ isLoading?: boolean; /** * Whether the input is disabled */ disabled?: boolean; /** * Default model to select */ defaultModel?: "sonnet" | "opus"; /** * Project path for file picker */ projectPath?: string; /** * Optional className for styling */ className?: string; } export interface FloatingPromptInputRef { addImage: (imagePath: string) => void; } type Model = { id: "sonnet" | "opus"; name: string; description: string; icon: React.ReactNode; }; const MODELS: Model[] = [ { id: "sonnet", name: "Claude 4 Sonnet", description: "Faster, efficient for most tasks", icon: }, { id: "opus", name: "Claude 4 Opus", description: "More capable, better for complex tasks", icon: } ]; /** * FloatingPromptInput component - Fixed position prompt input with model picker * * @example * const promptRef = useRef(null); * console.log('Send:', prompt, model)} * isLoading={false} * /> */ export const FloatingPromptInput = React.forwardRef(({ onSend, isLoading = false, disabled = false, defaultModel = "sonnet", projectPath, className, }, ref) => { const [prompt, setPrompt] = useState(""); const [selectedModel, setSelectedModel] = useState<"sonnet" | "opus">(defaultModel); const [isExpanded, setIsExpanded] = useState(false); const [modelPickerOpen, setModelPickerOpen] = useState(false); const [showFilePicker, setShowFilePicker] = useState(false); const [filePickerQuery, setFilePickerQuery] = useState(""); const [cursorPosition, setCursorPosition] = useState(0); const [embeddedImages, setEmbeddedImages] = useState([]); const [dragActive, setDragActive] = useState(false); const textareaRef = useRef(null); const expandedTextareaRef = useRef(null); const unlistenDragDropRef = useRef<(() => void) | null>(null); // Expose a method to add images programmatically React.useImperativeHandle( ref, () => ({ addImage: (imagePath: string) => { setPrompt(currentPrompt => { const existingPaths = extractImagePaths(currentPrompt); if (existingPaths.includes(imagePath)) { return currentPrompt; // Image already added } const mention = `@${imagePath}`; const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mention + ' '; // Focus the textarea setTimeout(() => { const target = isExpanded ? expandedTextareaRef.current : textareaRef.current; target?.focus(); target?.setSelectionRange(newPrompt.length, newPrompt.length); }, 0); return newPrompt; }); } }), [isExpanded] ); // Helper function to check if a file is an image const isImageFile = (path: string): boolean => { const ext = path.split('.').pop()?.toLowerCase(); return ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'].includes(ext || ''); }; // Extract image paths from prompt text const extractImagePaths = (text: string): string[] => { console.log('[extractImagePaths] Input text:', text); const regex = /@([^\s]+)/g; const matches = Array.from(text.matchAll(regex)); console.log('[extractImagePaths] Regex matches:', matches.map(m => m[0])); const pathsSet = new Set(); // Use Set to ensure uniqueness for (const match of matches) { const path = match[1]; console.log('[extractImagePaths] Processing path:', path); // Convert relative path to absolute if needed const fullPath = path.startsWith('/') ? path : (projectPath ? `${projectPath}/${path}` : path); console.log('[extractImagePaths] Full path:', fullPath, 'Is image:', isImageFile(fullPath)); if (isImageFile(fullPath)) { pathsSet.add(fullPath); // Add to Set (automatically handles duplicates) } } const uniquePaths = Array.from(pathsSet); // Convert Set back to Array console.log('[extractImagePaths] Final extracted paths (unique):', uniquePaths); return uniquePaths; }; // Update embedded images when prompt changes useEffect(() => { console.log('[useEffect] Prompt changed:', prompt); const imagePaths = extractImagePaths(prompt); console.log('[useEffect] Setting embeddedImages to:', imagePaths); setEmbeddedImages(imagePaths); }, [prompt, projectPath]); // Set up Tauri drag-drop event listener useEffect(() => { // This effect runs only once on component mount to set up the listener. let lastDropTime = 0; const setupListener = async () => { try { // If a listener from a previous mount/render is still around, clean it up. if (unlistenDragDropRef.current) { unlistenDragDropRef.current(); } const webview = getCurrentWebviewWindow(); unlistenDragDropRef.current = await webview.onDragDropEvent((event) => { if (event.payload.type === 'enter' || event.payload.type === 'over') { setDragActive(true); } else if (event.payload.type === 'leave') { setDragActive(false); } else if (event.payload.type === 'drop' && event.payload.paths) { setDragActive(false); const currentTime = Date.now(); if (currentTime - lastDropTime < 200) { // This debounce is crucial to handle the storm of drop events // that Tauri/OS can fire for a single user action. return; } lastDropTime = currentTime; const droppedPaths = event.payload.paths as string[]; const imagePaths = droppedPaths.filter(isImageFile); if (imagePaths.length > 0) { setPrompt(currentPrompt => { const existingPaths = extractImagePaths(currentPrompt); const newPaths = imagePaths.filter(p => !existingPaths.includes(p)); if (newPaths.length === 0) { return currentPrompt; // All dropped images are already in the prompt } const mentionsToAdd = newPaths.map(p => `@${p}`).join(' '); const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mentionsToAdd + ' '; setTimeout(() => { const target = isExpanded ? expandedTextareaRef.current : textareaRef.current; target?.focus(); target?.setSelectionRange(newPrompt.length, newPrompt.length); }, 0); return newPrompt; }); } } }); } catch (error) { console.error('Failed to set up Tauri drag-drop listener:', error); } }; setupListener(); return () => { // On unmount, ensure we clean up the listener. if (unlistenDragDropRef.current) { unlistenDragDropRef.current(); unlistenDragDropRef.current = null; } }; }, []); // Empty dependency array ensures this runs only on mount/unmount. useEffect(() => { // Focus the appropriate textarea when expanded state changes if (isExpanded && expandedTextareaRef.current) { expandedTextareaRef.current.focus(); } else if (!isExpanded && textareaRef.current) { textareaRef.current.focus(); } }, [isExpanded]); const handleSend = () => { if (prompt.trim() && !isLoading && !disabled) { onSend(prompt.trim(), selectedModel); setPrompt(""); setEmbeddedImages([]); } }; const handleTextChange = (e: React.ChangeEvent) => { const newValue = e.target.value; const newCursorPosition = e.target.selectionStart || 0; // Check if @ was just typed if (projectPath?.trim() && newValue.length > prompt.length && newValue[newCursorPosition - 1] === '@') { console.log('[FloatingPromptInput] @ detected, projectPath:', projectPath); setShowFilePicker(true); setFilePickerQuery(""); setCursorPosition(newCursorPosition); } // Check if we're typing after @ (for search query) if (showFilePicker && newCursorPosition >= cursorPosition) { // Find the @ position before cursor let atPosition = -1; for (let i = newCursorPosition - 1; i >= 0; i--) { if (newValue[i] === '@') { atPosition = i; break; } // Stop if we hit whitespace (new word) if (newValue[i] === ' ' || newValue[i] === '\n') { break; } } if (atPosition !== -1) { const query = newValue.substring(atPosition + 1, newCursorPosition); setFilePickerQuery(query); } else { // @ was removed or cursor moved away setShowFilePicker(false); setFilePickerQuery(""); } } setPrompt(newValue); setCursorPosition(newCursorPosition); }; const handleFileSelect = (entry: FileEntry) => { if (textareaRef.current) { // Replace the @ and partial query with the selected path (file or directory) const textarea = textareaRef.current; const beforeAt = prompt.substring(0, cursorPosition - 1); const afterCursor = prompt.substring(cursorPosition + filePickerQuery.length); const relativePath = entry.path.startsWith(projectPath || '') ? entry.path.slice((projectPath || '').length + 1) : entry.path; const newPrompt = `${beforeAt}@${relativePath} ${afterCursor}`; setPrompt(newPrompt); setShowFilePicker(false); setFilePickerQuery(""); // Focus back on textarea and set cursor position after the inserted path setTimeout(() => { textarea.focus(); const newCursorPos = beforeAt.length + relativePath.length + 2; // +2 for @ and space textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); } }; const handleFilePickerClose = () => { setShowFilePicker(false); setFilePickerQuery(""); // Return focus to textarea setTimeout(() => { textareaRef.current?.focus(); }, 0); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (showFilePicker && e.key === 'Escape') { e.preventDefault(); setShowFilePicker(false); setFilePickerQuery(""); return; } if (e.key === "Enter" && !e.shiftKey && !isExpanded && !showFilePicker) { e.preventDefault(); handleSend(); } }; // Browser drag and drop handlers - just prevent default behavior // Actual file handling is done via Tauri's window-level drag-drop events const handleDrag = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); // Visual feedback is handled by Tauri events }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); // File processing is handled by Tauri's onDragDropEvent }; const handleRemoveImage = (index: number) => { // Remove the corresponding @mention from the prompt const imagePath = embeddedImages[index]; const patterns = [ new RegExp(`@${imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g'), new RegExp(`@${imagePath.replace(projectPath + '/', '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g') ]; let newPrompt = prompt; for (const pattern of patterns) { newPrompt = newPrompt.replace(pattern, ''); } setPrompt(newPrompt.trim()); }; const selectedModelData = MODELS.find(m => m.id === selectedModel) || MODELS[0]; return ( <> {/* Expanded Modal */} {isExpanded && ( setIsExpanded(false)} > e.stopPropagation()} >

Compose your prompt

{/* Image previews in expanded mode */} {embeddedImages.length > 0 && ( )}