"use client"; import { useCallback, useEffect, useRef, useImperativeHandle, forwardRef, useState } from "react"; import { useAuth } from "@/lib/auth/auth-context"; import { useChat } from "@/hooks/useChat"; import { useOrchestratorCommands } from "@/hooks/useOrchestratorCommands"; import { useWebSocket } from "@/hooks/useWebSocket"; import { useWorkspaceId } from "@/lib/hooks"; import { MessageList } from "./MessageList"; import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput"; import { ChatEmptyState } from "./ChatEmptyState"; import type { Message } from "@/hooks/useChat"; export interface ChatRef { loadConversation: (conversationId: string) => Promise; startNewConversation: (projectId?: string | null) => void; getCurrentConversationId: () => string | null; } export interface NewConversationData { id: string; title: string | null; project_id: string | null; created_at: string; updated_at: string; } interface ChatProps { onConversationChange?: ( conversationId: string | null, conversationData?: NewConversationData ) => void; onProjectChange?: () => void; initialProjectId?: string | null; onInitialProjectHandled?: () => void; } const WAITING_QUIPS = [ "The AI is warming up... give it a moment.", "Loading the neural pathways...", "Waking up the LLM. It's not a morning model.", "Brewing some thoughts...", "The AI is stretching its parameters...", "Summoning intelligence from the void...", "Teaching electrons to think...", "Consulting the silicon oracle...", "The hamsters are spinning up the GPU...", "Defragmenting the neural networks...", ]; export const Chat = forwardRef(function Chat( { onConversationChange, onProjectChange: _onProjectChange, initialProjectId, onInitialProjectHandled: _onInitialProjectHandled, }, ref ) { void _onProjectChange; void _onInitialProjectHandled; const { user, isLoading: authLoading } = useAuth(); // Model and params state — initialized from ChatInput's persisted values const [selectedModel, setSelectedModel] = useState("llama3.2"); const [temperature, setTemperature] = useState(DEFAULT_TEMPERATURE); const [maxTokens, setMaxTokens] = useState(DEFAULT_MAX_TOKENS); // Suggestion fill value: controls ChatInput's textarea content const [suggestionValue, setSuggestionValue] = useState(undefined); const { messages, isLoading: isChatLoading, isStreaming, error, conversationId, conversationTitle, sendMessage, abortStream, loadConversation, startNewConversation, setMessages, clearError, } = useChat({ model: selectedModel, temperature, maxTokens, ...(initialProjectId !== undefined && { projectId: initialProjectId }), }); // Read workspace ID from localStorage (set by auth-context after session check). // Cookie-based auth (withCredentials) handles authentication, so no explicit // token is needed here — pass an empty string as the token placeholder. const workspaceId = useWorkspaceId() ?? ""; const { isConnected: isWsConnected } = useWebSocket(workspaceId, "", {}); const { isCommand, executeCommand } = useOrchestratorCommands(); const messagesEndRef = useRef(null); const inputRef = useRef(null); const [loadingQuip, setLoadingQuip] = useState(null); const quipTimerRef = useRef(null); const quipIntervalRef = useRef(null); // Identify the streaming message (last assistant message while streaming) const streamingMessageId = isStreaming && messages.length > 0 ? messages[messages.length - 1]?.id : undefined; // Whether the conversation is empty (only welcome message or no messages) const isEmptyConversation = messages.length === 0 || (messages.length === 1 && messages[0]?.id === "welcome" && !isChatLoading && !isStreaming); useImperativeHandle(ref, () => ({ loadConversation: async (cId: string): Promise => { await loadConversation(cId); }, startNewConversation: (projectId?: string | null): void => { startNewConversation(projectId); }, getCurrentConversationId: (): string | null => conversationId, })); const scrollToBottom = useCallback(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, []); useEffect(() => { scrollToBottom(); }, [messages, scrollToBottom]); useEffect(() => { if (conversationId && conversationTitle) { onConversationChange?.(conversationId, { id: conversationId, title: conversationTitle, project_id: initialProjectId ?? null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }); } else { onConversationChange?.(null); } }, [conversationId, conversationTitle, initialProjectId, onConversationChange]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent): void => { // Cmd/Ctrl + / : Focus input if ((e.ctrlKey || e.metaKey) && e.key === "/") { e.preventDefault(); inputRef.current?.focus(); } // Cmd/Ctrl + N : Start new conversation if ((e.ctrlKey || e.metaKey) && (e.key === "n" || e.key === "N")) { e.preventDefault(); startNewConversation(null); inputRef.current?.focus(); } // Cmd/Ctrl + L : Clear / start new conversation if ((e.ctrlKey || e.metaKey) && (e.key === "l" || e.key === "L")) { e.preventDefault(); startNewConversation(null); inputRef.current?.focus(); } }; document.addEventListener("keydown", handleKeyDown); return (): void => { document.removeEventListener("keydown", handleKeyDown); }; }, [startNewConversation]); // Show loading quips only during non-streaming load (initial fetch wait) useEffect(() => { if (isChatLoading && !isStreaming) { quipTimerRef.current = setTimeout(() => { setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null); }, 3000); quipIntervalRef.current = setInterval(() => { setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null); }, 5000); } else { if (quipTimerRef.current) { clearTimeout(quipTimerRef.current); quipTimerRef.current = null; } if (quipIntervalRef.current) { clearInterval(quipIntervalRef.current); quipIntervalRef.current = null; } setLoadingQuip(null); } return (): void => { if (quipTimerRef.current) clearTimeout(quipTimerRef.current); if (quipIntervalRef.current) clearInterval(quipIntervalRef.current); }; }, [isChatLoading, isStreaming]); const handleSendMessage = useCallback( async (content: string) => { if (isCommand(content)) { // Add user message immediately const userMessage: Message = { id: `user-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`, role: "user", content: content.trim(), createdAt: new Date().toISOString(), }; setMessages((prev) => [...prev, userMessage]); // Execute orchestrator command const result = await executeCommand(content); if (result) { setMessages((prev) => [...prev, result]); } return; } await sendMessage(content); }, [isCommand, executeCommand, setMessages, sendMessage] ); const handleSuggestionClick = useCallback((prompt: string): void => { setSuggestionValue(prompt); // Clear after a tick so input receives it, then focus setTimeout(() => { inputRef.current?.focus(); }, 0); }, []); if (authLoading) { return (
Loading...
); } return (
{/* Connection Status Indicator */} {user && !isWsConnected && (
Reconnecting to server...
)} {/* Messages Area */}
{isEmptyConversation ? ( ) : ( )}
{/* Error Alert */} {error && (
{error}
)} {/* Input Area */}
); });