/** * Assistant Chat Component * * Main chat interface for the project assistant. * Displays messages and handles user input. * Supports conversation history with resume functionality. */ import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { Send, Loader2, Wifi, WifiOff, Plus, History } from 'lucide-react' import { useAssistantChat } from '../hooks/useAssistantChat' import { ChatMessage as ChatMessageComponent } from './ChatMessage' import { ConversationHistory } from './ConversationHistory' import type { ChatMessage } from '../lib/types' interface AssistantChatProps { projectName: string conversationId?: number | null initialMessages?: ChatMessage[] isLoadingConversation?: boolean onNewChat?: () => void onSelectConversation?: (id: number) => void onConversationCreated?: (id: number) => void } export function AssistantChat({ projectName, conversationId, initialMessages, isLoadingConversation, onNewChat, onSelectConversation, onConversationCreated, }: AssistantChatProps) { const [inputValue, setInputValue] = useState('') const [showHistory, setShowHistory] = useState(false) const messagesEndRef = useRef(null) const inputRef = useRef(null) const hasStartedRef = useRef(false) const lastConversationIdRef = useRef(undefined) // Memoize the error handler to prevent infinite re-renders const handleError = useCallback((error: string) => { console.error('Assistant error:', error) }, []) const { messages, isLoading, connectionStatus, conversationId: activeConversationId, start, sendMessage, clearMessages, } = useAssistantChat({ projectName, onError: handleError, }) // Notify parent when a NEW conversation is created (not when switching to existing) // Track activeConversationId to fire callback only once when it transitions from null to a value const previousActiveConversationIdRef = useRef(activeConversationId) useEffect(() => { const hadNoConversation = previousActiveConversationIdRef.current === null const nowHasConversation = activeConversationId !== null if (hadNoConversation && nowHasConversation && onConversationCreated) { onConversationCreated(activeConversationId) } previousActiveConversationIdRef.current = activeConversationId }, [activeConversationId, onConversationCreated]) // Auto-scroll to bottom on new messages useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) // Start or resume the chat session when component mounts or conversationId changes useEffect(() => { // Skip if we're loading conversation details if (isLoadingConversation) { return } // Only start if conversationId has actually changed if (lastConversationIdRef.current === conversationId && hasStartedRef.current) { return } // Check if we're switching to a different conversation (not initial mount) const isSwitching = lastConversationIdRef.current !== undefined && lastConversationIdRef.current !== conversationId lastConversationIdRef.current = conversationId hasStartedRef.current = true // Clear existing messages when switching conversations if (isSwitching) { clearMessages() } // Start the session with the conversation ID (or null for new) start(conversationId) }, [conversationId, isLoadingConversation, start, clearMessages]) // Handle starting a new chat const handleNewChat = useCallback(() => { clearMessages() onNewChat?.() }, [clearMessages, onNewChat]) // Handle selecting a conversation from history const handleSelectConversation = useCallback((id: number) => { setShowHistory(false) onSelectConversation?.(id) }, [onSelectConversation]) // Focus input when not loading useEffect(() => { if (!isLoading) { inputRef.current?.focus() } }, [isLoading]) const handleSend = () => { const content = inputValue.trim() if (!content || isLoading || isLoadingConversation) return sendMessage(content) setInputValue('') } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSend() } } // Combine initial messages (from resumed conversation) with live messages // Merge both arrays with deduplication by message ID to prevent history loss const displayMessages = useMemo(() => { const isConversationSynced = lastConversationIdRef.current === conversationId && !isLoadingConversation // If not synced yet, show only initialMessages (or empty) if (!isConversationSynced) { return initialMessages ?? [] } // If no initial messages, just show live messages if (!initialMessages || initialMessages.length === 0) { return messages } // Merge both arrays, deduplicating by ID (live messages take precedence) const messageMap = new Map() for (const msg of initialMessages) { messageMap.set(msg.id, msg) } for (const msg of messages) { messageMap.set(msg.id, msg) } return Array.from(messageMap.values()) }, [initialMessages, messages, conversationId, isLoadingConversation]) return (
{/* Header with actions and connection status */}
{/* Action buttons */}
{/* History dropdown */} setShowHistory(false)} onSelectConversation={handleSelectConversation} />
{/* Connection status */}
{connectionStatus === 'connected' ? ( <> Connected ) : connectionStatus === 'connecting' ? ( <> Connecting... ) : ( <> Disconnected )}
{/* Messages area */}
{isLoadingConversation ? (
Loading conversation...
) : displayMessages.length === 0 ? (
{isLoading ? (
Connecting to assistant...
) : ( Ask me anything about the codebase )}
) : (
{displayMessages.map((message) => ( ))}
)}
{/* Loading indicator */} {isLoading && displayMessages.length > 0 && (
Thinking...
)} {/* Input area */}