/** * Expand Project Chat Component * * Full chat interface for interactive project expansion with Claude. * Allows users to describe new features in natural language. */ import { useCallback, useEffect, useRef, useState } from 'react' import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Paperclip, Plus } from 'lucide-react' import { useExpandChat } from '../hooks/useExpandChat' import { ChatMessage } from './ChatMessage' import { TypingIndicator } from './TypingIndicator' import type { ImageAttachment } from '../lib/types' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Card, CardContent } from '@/components/ui/card' import { Alert, AlertDescription } from '@/components/ui/alert' // Image upload validation constants const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB const ALLOWED_TYPES = ['image/jpeg', 'image/png'] interface ExpandProjectChatProps { projectName: string onComplete: (featuresAdded: number) => void onCancel: () => void } export function ExpandProjectChat({ projectName, onComplete, onCancel, }: ExpandProjectChatProps) { const [input, setInput] = useState('') const [error, setError] = useState(null) const [pendingAttachments, setPendingAttachments] = useState([]) const messagesEndRef = useRef(null) const inputRef = useRef(null) const fileInputRef = useRef(null) // Memoize error handler to keep hook dependencies stable const handleError = useCallback((err: string) => setError(err), []) const { messages, isLoading, isComplete, connectionStatus, featuresCreated, start, sendMessage, disconnect, } = useExpandChat({ projectName, onComplete, onError: handleError, }) // Start the chat session when component mounts useEffect(() => { start() return () => { disconnect() } }, []) // eslint-disable-line react-hooks/exhaustive-deps // Scroll to bottom when messages change useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages, isLoading]) // Focus input when not loading useEffect(() => { if (!isLoading && inputRef.current) { inputRef.current.focus() } }, [isLoading]) const handleSendMessage = () => { const trimmed = input.trim() // Allow sending if there's text OR attachments if ((!trimmed && pendingAttachments.length === 0) || isLoading) return sendMessage(trimmed, pendingAttachments.length > 0 ? pendingAttachments : undefined) setInput('') setPendingAttachments([]) // Clear attachments after sending } const handleKeyDown = (e: React.KeyboardEvent) => { // Skip if composing (e.g., Japanese IME input) if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault() handleSendMessage() } } // File handling for image attachments const handleFileSelect = useCallback((files: FileList | null) => { if (!files) return Array.from(files).forEach((file) => { // Validate file type if (!ALLOWED_TYPES.includes(file.type)) { setError(`Invalid file type: ${file.name}. Only JPEG and PNG are supported.`) return } // Validate file size if (file.size > MAX_FILE_SIZE) { setError(`File too large: ${file.name}. Maximum size is 5 MB.`) return } // Read and convert to base64 const reader = new FileReader() reader.onload = (e) => { const dataUrl = e.target?.result as string const base64Data = dataUrl.split(',')[1] const attachment: ImageAttachment = { id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, filename: file.name, mimeType: file.type as 'image/jpeg' | 'image/png', base64Data, previewUrl: dataUrl, size: file.size, } setPendingAttachments((prev) => [...prev, attachment]) } reader.onerror = () => { setError(`Failed to read file: ${file.name}`) } reader.readAsDataURL(file) }) }, []) const handleRemoveAttachment = useCallback((id: string) => { setPendingAttachments((prev) => prev.filter((a) => a.id !== id)) }, []) const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault() handleFileSelect(e.dataTransfer.files) }, [handleFileSelect] ) const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault() }, []) // Connection status indicator const ConnectionIndicator = () => { switch (connectionStatus) { case 'connected': return ( Connected ) case 'connecting': return ( Connecting... ) case 'error': return ( Error ) default: return ( Disconnected ) } } return (
{/* Header */}

Expand Project: {projectName}

{featuresCreated > 0 && ( {featuresCreated} added )}
{isComplete && ( Complete )}
{/* Error banner */} {error && ( {error} )} {/* Messages area */}
{messages.length === 0 && !isLoading && (

Starting Project Expansion

Connecting to Claude to help you add new features to your project...

{connectionStatus === 'error' && ( )}
)} {messages.map((message) => ( ))} {/* Typing indicator */} {isLoading && } {/* Scroll anchor */}
{/* Input area */} {!isComplete && (
{/* Attachment previews */} {pendingAttachments.length > 0 && (
{pendingAttachments.map((attachment) => (
{attachment.filename} {attachment.filename.length > 10 ? `${attachment.filename.substring(0, 7)}...` : attachment.filename}
))}
)}
{/* Hidden file input */} handleFileSelect(e.target.files)} className="hidden" /> {/* Attach button */} setInput(e.target.value)} onKeyDown={handleKeyDown} placeholder={ pendingAttachments.length > 0 ? 'Add a message with your image(s)...' : 'Describe the features you want to add...' } className="flex-1" disabled={isLoading || connectionStatus !== 'connected'} />
{/* Help text */}

Press Enter to send. Drag & drop or click to attach images.

)} {/* Completion footer */} {isComplete && (
Added {featuresCreated} new feature{featuresCreated !== 1 ? 's' : ''}!
)}
) }