import { useCallback, useEffect, useRef, useState } from "react"; import { MessageSquare, Send, Plus, Search, Trash2, Archive, ChevronLeft, Bot, } from "lucide-react"; import { useChat } from "../hooks/useChat"; import { useViewportMode } from "./Header"; import { fetchAgents } from "../api"; import type { Agent } from "@fusion/core"; export interface ChatViewProps { projectId?: string; addToast: (msg: string, type?: "success" | "error") => void; } function formatRelativeTime(dateStr: string): string { const date = new Date(dateStr); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffSecs = Math.floor(diffMs / 1000); const diffMins = Math.floor(diffSecs / 60); const diffHours = Math.floor(diffMins / 60); const diffDays = Math.floor(diffHours / 24); if (diffSecs < 60) return "just now"; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); } /** * Format a model provider and ID into a human-readable tag. * Returns null if provider or modelId is missing/empty. */ function formatModelTag(provider?: string | null, modelId?: string | null): string | null { if (!provider || !modelId) return null; // Handle known provider/model patterns const normalizedModel = modelId.toLowerCase(); // Claude models: "claude-sonnet-4-5" -> "Claude Sonnet 4.5" if (normalizedModel.includes("claude")) { let formatted = modelId .replace(/^claude[- ]/i, "Claude ") .replace(/sonnet[- ](\d+)[- ](\d+)/i, "Sonnet $1.$2") .replace(/sonnet[- ](\d+)/i, "Sonnet $1") .replace(/haiku[- ](\d+)/i, "Haiku $1") .replace(/opus[- ](\d+)/i, "Opus $1") .replace(/sonnet/i, "Sonnet") .replace(/haiku/i, "Haiku") .replace(/opus/i, "Opus") .replace(/-/g, " ") .trim(); // Fix double spaces formatted = formatted.replace(/\s+/g, " "); return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted; } // OpenAI models: "gpt-4o" -> "GPT-4o", "gpt-4-turbo" -> "GPT-4 Turbo" if (normalizedModel.includes("gpt") || normalizedModel.includes("openai")) { // Format GPT model names: handle special cases first, then capitalize // Note: We don't replace hyphens globally because special cases preserve them const formatted = modelId .replace(/^gpt-4-turbo$/i, "GPT-4 Turbo") .replace(/^gpt-4o-mini$/i, "GPT-4o Mini") .replace(/^gpt-4o$/i, "GPT-4o") .replace(/^gpt-4$/i, "GPT-4") .replace(/^gpt-o1-preview$/i, "GPT-o1 Preview") .replace(/^gpt-o1-mini$/i, "GPT-o1 Mini") .replace(/^gpt-o1$/i, "GPT-o1") .replace(/^gpt/i, "GPT") // Capitalize remaining GPT prefix .trim(); return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted; } // Gemini models: "gemini-2.5-pro" -> "Gemini 2.5 Pro" if (normalizedModel.includes("gemini")) { let formatted = modelId .replace(/^gemini[- ]/i, "Gemini ") .replace(/pro[- ](\d+)[- ](\d+)/i, "Pro $1.$2") .replace(/pro[- ](\d+)/i, "Pro $1") .replace(/-/g, " ") .replace(/\s+/g, " ") .trim(); return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted; } // Generic fallback: capitalize first letter, replace hyphens with spaces let formatted = modelId .replace(/-/g, " ") .replace(/^\w/, (c) => c.toUpperCase()) .replace(/\s+/g, " ") .trim(); return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted; } /** * Constant agent ID for the built-in kb agent. * The chat system always uses createKbAgent with CHAT_SYSTEM_PROMPT regardless * of the agentId stored on the session. This ID serves as metadata only. */ const KB_AGENT_ID = "__kb_agent__"; interface NewChatDialogProps { onClose: () => void; onCreate: (input: { agentId: string }) => void; } function NewChatDialog({ onClose, onCreate }: NewChatDialogProps) { const [agents, setAgents] = useState([]); const [agentsLoading, setAgentsLoading] = useState(true); const [selectedAgentId, setSelectedAgentId] = useState(""); // Load agents on mount useEffect(() => { setAgentsLoading(true); fetchAgents() .then((response) => { setAgents(response); }) .catch(() => { // Silently fail - show empty list setAgents([]); }) .finally(() => { setAgentsLoading(false); }); }, []); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!selectedAgentId) return; onCreate({ agentId: selectedAgentId }); }; return (
e.stopPropagation()}>

New Chat

); } export function ChatView({ projectId, addToast }: ChatViewProps) { const { activeSession, sessionsLoading, messages, messagesLoading, isStreaming, streamingText, streamingThinking, selectSession, createSession, archiveSession, deleteSession, sendMessage, searchQuery, setSearchQuery, filteredSessions, } = useChat(projectId); const [showNewDialog, setShowNewDialog] = useState(false); const [messageInput, setMessageInput] = useState(""); const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null); const [confirmDelete, setConfirmDelete] = useState(null); const [sidebarVisible, setSidebarVisible] = useState(true); const [agentsMap, setAgentsMap] = useState>(new Map()); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const inputRef = useRef(null); const mode = useViewportMode(); const isMobile = mode === "mobile"; // Scroll to bottom on new messages or streaming useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, streamingText]); // Close context menu on outside click useEffect(() => { const handleClick = () => setContextMenu(null); if (contextMenu) { document.addEventListener("click", handleClick); return () => document.removeEventListener("click", handleClick); } }, [contextMenu]); // Fetch agents on mount for name resolution useEffect(() => { fetchAgents() .then((agents) => { const map = new Map(); for (const agent of agents) { map.set(agent.id, agent); } setAgentsMap(map); }) .catch(() => { // Silently fail - keep empty map }); }, []); // Handle create session const handleCreateSession = useCallback( async (input: { agentId: string }) => { try { await createSession(input); setShowNewDialog(false); // On mobile, hide sidebar after selecting if (isMobile) setSidebarVisible(false); } catch { addToast("Failed to create chat session", "error"); } }, [createSession, addToast, isMobile], ); // Handle send message const handleSend = useCallback(async () => { const trimmed = messageInput.trim(); if (!trimmed || isStreaming || !activeSession) return; setMessageInput(""); try { await sendMessage(trimmed); } catch { addToast("Failed to send message", "error"); } }, [messageInput, isStreaming, activeSession, sendMessage, addToast]); // Handle input key down const handleInputKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSend(); } }, [handleSend], ); // Handle textarea resize const handleInputChange = useCallback((e: React.ChangeEvent) => { const textarea = e.target; setMessageInput(textarea.value); textarea.style.height = "auto"; textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`; }, []); // Handle archive const handleArchive = useCallback( async (id: string) => { setContextMenu(null); try { await archiveSession(id); addToast("Conversation archived", "success"); } catch { addToast("Failed to archive conversation", "error"); } }, [archiveSession, addToast], ); // Handle delete const handleDelete = useCallback( async (id: string) => { setConfirmDelete(null); setContextMenu(null); try { await deleteSession(id); addToast("Conversation deleted", "success"); } catch { addToast("Failed to delete conversation", "error"); } }, [deleteSession, addToast], ); // Handle session click const handleSessionClick = useCallback( (id: string) => { selectSession(id); if (isMobile) setSidebarVisible(false); }, [selectSession, isMobile], ); // Handle back to sidebar (mobile) const handleBack = useCallback(() => { selectSession(""); setSidebarVisible(true); }, [selectSession]); // Render empty state (no active session) const renderEmptyState = () => { if (showNewDialog) { return ( setShowNewDialog(false)} onCreate={handleCreateSession} /> ); } return (

Start a new conversation

); }; return (
{/* Sidebar */}
setSearchQuery(e.target.value)} data-testid="chat-search-input" />
{sessionsLoading ? (
Loading...
) : filteredSessions.length === 0 ? (
No conversations yet
) : ( filteredSessions.map((session) => (
handleSessionClick(session.id)} onContextMenu={(e) => { e.preventDefault(); setContextMenu({ sessionId: session.id, x: e.clientX, y: e.clientY }); }} data-testid={`chat-session-${session.id}`} >
{session.title || "Untitled"}
{session.lastMessagePreview || "No messages"}
{agentsMap.get(session.agentId)?.name || (session.agentId === KB_AGENT_ID ? "Fusion" : session.agentId.slice(0, 30))} {session.updatedAt ? formatRelativeTime(session.updatedAt) : ""}
)) )}
{/* Context Menu */} {contextMenu && (
e.stopPropagation()} >
)} {/* Confirm Delete Dialog */} {confirmDelete && (
setConfirmDelete(null)}>
e.stopPropagation()}>

Delete Conversation?

This action cannot be undone. All messages in this conversation will be permanently deleted.

)} {/* Thread */}
{/* Header */}
{isMobile && ( )} {activeSession?.agentId === KB_AGENT_ID ? "Fusion" : activeSession?.title || agentsMap.get(activeSession?.agentId ?? "")?.name || activeSession?.agentId || "Chat"} {activeSession && (() => { const modelTag = formatModelTag(activeSession.modelProvider, activeSession.modelId); return modelTag ? {modelTag} : null; })()}
{/* Messages */}
{messagesLoading ? (
Loading messages...
) : messages.length === 0 && !activeSession ? ( renderEmptyState() ) : messages.length === 0 && activeSession ? (
No messages yet. Start the conversation!
) : ( <> {messages.map((message) => (
{message.role === "assistant" && (
Fusion {activeSession && (() => { const modelTag = formatModelTag(activeSession.modelProvider, activeSession.modelId); return modelTag ? {modelTag} : null; })()}
)}
{message.content}
{message.thinkingOutput && (
Thinking
{message.thinkingOutput}
)}
{formatRelativeTime(message.createdAt)}
))} {isStreaming && streamingText && (
Fusion {activeSession && (() => { const modelTag = formatModelTag(activeSession.modelProvider, activeSession.modelId); return modelTag ? {modelTag} : null; })()}
{streamingText}
{streamingThinking && (
Thinking
{streamingThinking}
)}
)} )}
{/* Input */} {activeSession && (