import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } 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"; import { AgentMentionPopup } from "./AgentMentionPopup"; 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, }; } function getMentionTriggerMatch( value: string, cursorPos: number, ): { filter: string; start: number; end: number } | null { const textBeforeCursor = value.slice(0, cursorPos); const triggerMatch = /(^|[\s\n])@([\w-]*)$/.exec(textBeforeCursor); if (!triggerMatch) { return null; } const filter = triggerMatch[2] ?? ""; const start = textBeforeCursor.length - filter.length - 1; return { filter, start, end: cursorPos, }; } interface NewChatDialogProps { onClose: () => void; onCreate: (input: { agentId: string; modelProvider?: string; modelId?: string }) => void; } function NewChatDialog({ onClose, onCreate }: NewChatDialogProps) { const [chatMode, setChatMode] = useState<"agent" | "model">("agent"); 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 (chatMode === "agent") { if (!selectedAgentId) return; onCreate({ agentId: selectedAgentId }); return; } // model mode if (!selectedModel) return; const slashIdx = selectedModel.indexOf("/"); if (slashIdx <= 0) return; const modelProvider = selectedModel.slice(0, slashIdx); const modelId = selectedModel.slice(slashIdx + 1); onCreate({ agentId: KB_AGENT_ID, modelProvider, modelId }); }; const isSubmitDisabled = chatMode === "agent" ? !selectedAgentId : !selectedModel; return (
e.stopPropagation()}>

New Chat

{chatMode === "agent" && ( )} {chatMode === "model" && (
{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 [mentionFilter, setMentionFilter] = useState(""); const [mentionPopupVisible, setMentionPopupVisible] = useState(false); const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0); const [mentionStartPos, setMentionStartPos] = useState(-1); const messagesEndRef = useRef(null); const hideSkillMenuTimeoutRef = useRef(null); const messagesContainerRef = useRef(null); const inputRef = useRef(null); const mentionCursorPosRef = useRef(0); 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]); const mentionAgents = useMemo(() => Array.from(agentsMap.values()), [agentsMap]); const filteredMentionAgents = useMemo(() => { const normalizedFilter = mentionFilter.trim().toLowerCase(); if (!normalizedFilter) { return mentionAgents; } return mentionAgents.filter((agent) => agent.name.toLowerCase().includes(normalizedFilter)); }, [mentionAgents, mentionFilter]); const mentionAgentsByName = useMemo(() => { const byName = new Map(); for (const agent of mentionAgents) { byName.set(agent.name.toLowerCase(), agent); } return byName; }, [mentionAgents]); useEffect(() => { setHighlightedSkillIndex(0); }, [filteredSkills]); useEffect(() => { setMentionHighlightIndex(0); }, [mentionFilter, mentionPopupVisible]); 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(() => { const trimmed = messageInput.trim(); if (!trimmed || !activeSession) return; setMessageInput(""); setShowSkillMenu(false); setSkillFilter(""); setMentionPopupVisible(false); setMentionFilter(""); setMentionStartPos(-1); sendMessage(trimmed); }, [messageInput, activeSession, sendMessage]); 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); }, [], ); const handleMentionSelect = useCallback( (agent: Agent) => { const textarea = inputRef.current; if (!textarea || mentionStartPos < 0) { return; } const selectionStart = textarea.selectionStart ?? mentionCursorPosRef.current; const selectionEnd = textarea.selectionEnd ?? selectionStart; const cursorPos = Math.max(selectionStart, selectionEnd); const safeStart = Math.min(mentionStartPos, cursorPos); const mentionText = `@${agent.name.replace(/\s+/g, "_")}`; const replacement = `${mentionText} `; const nextInput = messageInput.slice(0, safeStart) + replacement + messageInput.slice(cursorPos); const nextCursorPos = safeStart + replacement.length; setMessageInput(nextInput); setMentionPopupVisible(false); setMentionFilter(""); setMentionHighlightIndex(0); setMentionStartPos(-1); 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(); inputRef.current.setSelectionRange(nextCursorPos, nextCursorPos); }); }, [mentionStartPos, messageInput], ); const renderMessageContent = useCallback( (content: string) => { const mentionRegex = /@([\w-]+)/g; const parts: ReactNode[] = []; let lastIndex = 0; let match = mentionRegex.exec(content); while (match) { const [fullMatch, rawName = ""] = match; const start = match.index; if (start > lastIndex) { parts.push(content.slice(lastIndex, start)); } const normalizedName = rawName.replace(/_/g, " ").toLowerCase(); const mentionedAgent = mentionAgentsByName.get(normalizedName); if (mentionedAgent) { parts.push( @{mentionedAgent.name.replace(/\s+/g, "_")} , ); } else { parts.push(fullMatch); } lastIndex = start + fullMatch.length; match = mentionRegex.exec(content); } if (lastIndex < content.length) { parts.push(content.slice(lastIndex)); } if (parts.length === 0) { return content; } return parts; }, [mentionAgentsByName], ); // Handle input key down const handleInputKeyDown = useCallback( (e: React.KeyboardEvent) => { mentionCursorPosRef.current = e.currentTarget.selectionStart ?? mentionCursorPosRef.current; if (mentionPopupVisible && e.key === "ArrowDown") { e.preventDefault(); if (filteredMentionAgents.length > 0) { setMentionHighlightIndex((prev) => (prev + 1) % filteredMentionAgents.length); } return; } if (mentionPopupVisible && e.key === "ArrowUp") { e.preventDefault(); if (filteredMentionAgents.length > 0) { setMentionHighlightIndex((prev) => prev === 0 ? filteredMentionAgents.length - 1 : prev - 1, ); } return; } if (mentionPopupVisible && e.key === "Enter") { e.preventDefault(); const agentToSelect = filteredMentionAgents[mentionHighlightIndex] ?? filteredMentionAgents[0]; if (agentToSelect) { handleMentionSelect(agentToSelect); } return; } if (mentionPopupVisible && e.key === "Escape") { e.preventDefault(); setMentionPopupVisible(false); setMentionFilter(""); setMentionStartPos(-1); return; } 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(); } }, [ mentionPopupVisible, filteredMentionAgents, mentionHighlightIndex, handleMentionSelect, showSkillMenu, filteredSkills, highlightedSkillIndex, handleSkillSelect, handleSend, ], ); const updateMentionState = useCallback((value: string, cursorPos: number) => { const mentionTriggerMatch = getMentionTriggerMatch(value, cursorPos); if (mentionTriggerMatch) { setMentionPopupVisible(true); setMentionFilter(mentionTriggerMatch.filter); setMentionStartPos(mentionTriggerMatch.start); return; } setMentionPopupVisible(false); setMentionFilter(""); setMentionStartPos(-1); }, []); // Handle textarea resize const handleInputChange = useCallback((e: React.ChangeEvent) => { const textarea = e.target; const nextValue = textarea.value; const cursorPos = textarea.selectionStart ?? nextValue.length; mentionCursorPosRef.current = cursorPos; setMessageInput(nextValue); const skillTriggerMatch = getSkillTriggerMatch(nextValue); if (skillTriggerMatch) { setShowSkillMenu(true); setSkillFilter(skillTriggerMatch.filter); } else { setShowSkillMenu(false); setSkillFilter(""); } updateMentionState(nextValue, cursorPos); textarea.style.height = "auto"; textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`; }, [updateMentionState]); const handleInputSelectionChange = useCallback( (e: React.SyntheticEvent) => { const textarea = e.currentTarget; const cursorPos = textarea.selectionStart ?? textarea.value.length; mentionCursorPosRef.current = cursorPos; updateMentionState(textarea.value, cursorPos); }, [updateMentionState], ); const handleInputKeyUp = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape") { return; } handleInputSelectionChange(e); }, [handleInputSelectionChange], ); const handleInputBlur = useCallback(() => { if (hideSkillMenuTimeoutRef.current !== null) { window.clearTimeout(hideSkillMenuTimeoutRef.current); } hideSkillMenuTimeoutRef.current = window.setTimeout(() => { setShowSkillMenu(false); setMentionPopupVisible(false); setMentionFilter(""); setMentionStartPos(-1); 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; })()}
)}
{renderMessageContent(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; })()}
{renderMessageContent(streamingText)}
{streamingThinking && (
Thinking
{streamingThinking}
)}
)} )}
{/* Input */} {activeSession && (
{showSkillMenu && (
{skillsLoading ? (
Loading skills…
) : filteredSkills.length === 0 ? (
{skillFilter ? "No skills found" : "No skills available"}
) : ( filteredSkills.map((skill, index) => ( )) )}
)}