import { useCallback, useEffect, useMemo, 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, fetchDiscoveredSkills, fetchModels } from "../api"; import type { Agent } from "@fusion/core"; import type { DiscoveredSkill } from "@fusion/dashboard"; import type { ModelInfo } from "../api"; import { CustomModelDropdown } from "./CustomModelDropdown"; 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__"; function getSkillTriggerMatch(value: string): { filter: string; start: number; end: number } | null { const triggerMatch = /(^|[\s])\/([^\s]*)$/.exec(value); if (!triggerMatch) { return null; } const prefix = triggerMatch[1] ?? ""; const filter = triggerMatch[2] ?? ""; const start = triggerMatch.index + prefix.length; return { filter, start, end: value.length, }; } interface NewChatDialogProps { onClose: () => void; onCreate: (input: { agentId: string; modelProvider?: string; modelId?: string }) => void; } function NewChatDialog({ onClose, onCreate }: NewChatDialogProps) { const [agents, setAgents] = useState([]); const [agentsLoading, setAgentsLoading] = useState(true); const [selectedAgentId, setSelectedAgentId] = useState(""); const [models, setModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(true); const [selectedModel, setSelectedModel] = useState(""); // Load agents on mount useEffect(() => { setAgentsLoading(true); fetchAgents() .then((response) => { setAgents(response); }) .catch(() => { // Silently fail - show empty list setAgents([]); }) .finally(() => { setAgentsLoading(false); }); }, []); // Load models on mount useEffect(() => { setModelsLoading(true); fetchModels() .then((response) => { setModels(response.models); }) .catch(() => { // Silently fail - show empty list setModels([]); }) .finally(() => { setModelsLoading(false); }); }, []); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!selectedAgentId) return; // Parse model selection into provider and modelId let modelProvider: string | undefined; let modelId: string | undefined; if (selectedModel) { const slashIdx = selectedModel.indexOf("/"); if (slashIdx > 0) { modelProvider = selectedModel.slice(0, slashIdx); modelId = selectedModel.slice(slashIdx + 1); } } onCreate({ agentId: selectedAgentId, ...(modelProvider && modelId ? { modelProvider, modelId } : {}), }); }; return (
e.stopPropagation()}>

New Chat

{modelsLoading ? (
Loading models...
) : ( )}
); } 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 [discoveredSkills, setDiscoveredSkills] = useState([]); const [skillsLoading, setSkillsLoading] = useState(true); const [showSkillMenu, setShowSkillMenu] = useState(false); const [skillFilter, setSkillFilter] = useState(""); const [highlightedSkillIndex, setHighlightedSkillIndex] = useState(0); const messagesEndRef = useRef(null); const hideSkillMenuTimeoutRef = useRef(null); const messagesContainerRef = useRef(null); const inputRef = useRef(null); const mode = useViewportMode(); const isMobile = mode === "mobile"; const filteredSkills = useMemo(() => { const normalizedFilter = skillFilter.trim().toLowerCase(); const matchingSkills = normalizedFilter ? discoveredSkills.filter((skill) => skill.name.toLowerCase().includes(normalizedFilter)) : discoveredSkills; return matchingSkills.slice(0, 10); }, [discoveredSkills, skillFilter]); useEffect(() => { setHighlightedSkillIndex(0); }, [filteredSkills]); useEffect(() => { return () => { if (hideSkillMenuTimeoutRef.current !== null) { window.clearTimeout(hideSkillMenuTimeoutRef.current); } }; }, []); // 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 }); }, []); // Fetch discovered skills for slash command autocomplete useEffect(() => { let cancelled = false; setSkillsLoading(true); fetchDiscoveredSkills(projectId) .then((skills) => { if (!cancelled) { setDiscoveredSkills(skills); } }) .catch(() => { if (!cancelled) { setDiscoveredSkills([]); } }) .finally(() => { if (!cancelled) { setSkillsLoading(false); } }); return () => { cancelled = true; }; }, [projectId]); // Handle create session const handleCreateSession = useCallback( async (input: { agentId: string; modelProvider?: string; modelId?: 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(""); setShowSkillMenu(false); setSkillFilter(""); try { await sendMessage(trimmed); } catch { addToast("Failed to send message", "error"); } }, [messageInput, isStreaming, activeSession, sendMessage, addToast]); const handleSkillSelect = useCallback( (skill: DiscoveredSkill) => { setMessageInput((currentInput) => { const triggerMatch = getSkillTriggerMatch(currentInput); if (!triggerMatch) { return currentInput; } const replacement = `/skill:${skill.name} `; const nextInput = currentInput.slice(0, triggerMatch.start) + replacement + currentInput.slice(triggerMatch.end); window.requestAnimationFrame(() => { if (!inputRef.current) return; inputRef.current.style.height = "auto"; inputRef.current.style.height = `${Math.min(inputRef.current.scrollHeight, 120)}px`; inputRef.current.focus(); }); return nextInput; }); setShowSkillMenu(false); setSkillFilter(""); setHighlightedSkillIndex(0); }, [], ); // Handle input key down const handleInputKeyDown = useCallback( (e: React.KeyboardEvent) => { if (showSkillMenu && e.key === "ArrowDown") { e.preventDefault(); if (filteredSkills.length > 0) { setHighlightedSkillIndex((prev) => (prev + 1) % filteredSkills.length); } return; } if (showSkillMenu && e.key === "ArrowUp") { e.preventDefault(); if (filteredSkills.length > 0) { setHighlightedSkillIndex((prev) => prev === 0 ? filteredSkills.length - 1 : prev - 1, ); } return; } if (showSkillMenu && (e.key === "Enter" || e.key === "Tab") && filteredSkills.length > 0) { e.preventDefault(); const skillToSelect = filteredSkills[highlightedSkillIndex] ?? filteredSkills[0]; if (skillToSelect) { handleSkillSelect(skillToSelect); } return; } if (showSkillMenu && e.key === "Escape") { e.preventDefault(); setShowSkillMenu(false); return; } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSend(); } }, [showSkillMenu, filteredSkills, highlightedSkillIndex, handleSkillSelect, handleSend], ); // Handle textarea resize const handleInputChange = useCallback((e: React.ChangeEvent) => { const textarea = e.target; const nextValue = textarea.value; setMessageInput(nextValue); const triggerMatch = getSkillTriggerMatch(nextValue); if (triggerMatch) { setShowSkillMenu(true); setSkillFilter(triggerMatch.filter); } else { setShowSkillMenu(false); setSkillFilter(""); } textarea.style.height = "auto"; textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`; }, []); const handleInputBlur = useCallback(() => { if (hideSkillMenuTimeoutRef.current !== null) { window.clearTimeout(hideSkillMenuTimeoutRef.current); } hideSkillMenuTimeoutRef.current = window.setTimeout(() => { setShowSkillMenu(false); hideSkillMenuTimeoutRef.current = null; }, 120); }, []); const handleInputFocus = useCallback(() => { if (hideSkillMenuTimeoutRef.current !== null) { window.clearTimeout(hideSkillMenuTimeoutRef.current); hideSkillMenuTimeoutRef.current = null; } }, []); // 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

); }; const agentName = agentsMap.get(activeSession?.agentId ?? "")?.name || (activeSession?.agentId === KB_AGENT_ID ? "Fusion" : (activeSession?.agentId?.slice(0, 30) ?? "Fusion")); 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" && (
{agentName} {activeSession && (() => { const modelTag = formatModelTag(activeSession.modelProvider, activeSession.modelId); return modelTag ? {modelTag} : null; })()}
)}
{message.content}
{message.thinkingOutput && (
Thinking
{message.thinkingOutput}
)}
{formatRelativeTime(message.createdAt)}
))} {isStreaming && streamingText && (
{agentName} {activeSession && (() => { const modelTag = formatModelTag(activeSession.modelProvider, activeSession.modelId); return modelTag ? {modelTag} : null; })()}
{streamingText}
{streamingThinking && (
Thinking
{streamingThinking}
)}
)} )}
{/* Input */} {activeSession && (
{showSkillMenu && (
{skillsLoading ? (
Loading skills…
) : filteredSkills.length === 0 ? (
{skillFilter ? "No skills found" : "No skills available"}
) : ( filteredSkills.map((skill, index) => ( )) )}
)}