// ChatView.css is imported eagerly from App.tsx to avoid a flash of // unstyled content when the lazy chunk loads. Do not re-import here. import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { Components } from "react-markdown"; import { MessageSquare, Send, Plus, Search, Trash2, Archive, ChevronLeft, Bot, Square, Eye, EyeOff, Paperclip, File, Wrench, } from "lucide-react"; import { useChat, type ChatMessageInfo, type ToolCallInfo } from "../hooks/useChat"; import { useViewportMode } from "./Header"; import { fetchAgents, fetchDiscoveredSkills, fetchModels, updateGlobalSettings } 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"; import { FileMentionPopup } from "./FileMentionPopup"; import { useFileMention } from "../hooks/useFileMention"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; export interface ChatViewProps { projectId?: string; addToast: (msg: string, type?: "success" | "error" | "warning") => 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")) { const 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 const formatted = modelId .replace(/-/g, " ") .replace(/^\w/, (c) => c.toUpperCase()) .replace(/\s+/g, " ") .trim(); return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted; } function truncateToolValue(value: string, maxLength: number): string { if (value.length <= maxLength) return value; return `${value.slice(0, maxLength)}…`; } function formatToolArgsSummary(args?: Record): string | null { if (!args) return null; const entries = Object.entries(args); if (entries.length === 0) return null; return entries .map(([key, value]) => { const stringValue = typeof value === "string" ? value : (() => { try { return JSON.stringify(value); } catch { return String(value); } })(); return `${key}=${truncateToolValue(stringValue, 50)}`; }) .join(", "); } function formatToolResultSummary(result: unknown): string | null { if (result === undefined) return null; if (typeof result === "string") return truncateToolValue(result, 200); try { return truncateToolValue(JSON.stringify(result), 200); } catch { return truncateToolValue(String(result), 200); } } function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode { if (!toolCalls || toolCalls.length === 0) return null; const renderToolCallItem = (toolCall: ToolCallInfo, index: number) => { const isRunning = toolCall.status === "running"; const isError = toolCall.status === "completed" && toolCall.isError; const argsSummary = formatToolArgsSummary(toolCall.args); const resultSummary = formatToolResultSummary(toolCall.result); const summaryPreview = isRunning ? argsSummary : resultSummary ? `result: ${resultSummary}` : argsSummary ? `args: ${argsSummary}` : null; const statusLabel = isRunning ? "running" : isError ? "error" : "completed"; return (
{argsSummary && (
args {argsSummary}
)} {resultSummary && (
result {resultSummary}
)}
); }; const className = "chat-tool-calls"; if (toolCalls.length === 1) { return (
{renderToolCallItem(toolCalls[0], 0)}
); } const runningCount = toolCalls.filter((toolCall) => toolCall.status === "running").length; const errorCount = toolCalls.filter((toolCall) => toolCall.status === "completed" && toolCall.isError).length; const hasRunning = runningCount > 0; const uniqueNames = Array.from(new Set(toolCalls.map((toolCall) => toolCall.toolName))); const visibleNames = uniqueNames.slice(0, 5); const overflowCount = Math.max(0, uniqueNames.length - visibleNames.length); const namesSummary = overflowCount > 0 ? `${visibleNames.join(", ")}, +${overflowCount} more` : visibleNames.join(", "); const statusSummary = hasRunning ? `(${runningCount} running)` : errorCount > 0 ? `(${errorCount} ${errorCount === 1 ? "error" : "errors"})` : null; return (
{toolCalls.map((toolCall, index) => renderToolCallItem(toolCall, index))}
); } const chatMarkdownComponents: Components = { pre: ({ children, ...props }) => (
      {children}
    
), table: ({ children, ...props }) => ( {children}
), }; /** * Constant agent ID for the built-in fn agent. * The chat system always uses createFnAgent with CHAT_SYSTEM_PROMPT regardless * of the agentId stored on the session. This ID serves as metadata only. */ const FN_AGENT_ID = "__fn_agent__"; const CHAT_SIDEBAR_DEFAULT_WIDTH = 280; const CHAT_SIDEBAR_MIN_WIDTH = 180; const CHAT_SIDEBAR_MAX_WIDTH = 500; const CHAT_SIDEBAR_STORAGE_KEY = "fusion:chat-sidebar-width"; interface PendingAttachment { file: File; previewUrl: string; } const ALLOWED_ATTACHMENT_TYPES = [ "image/png", "image/jpeg", "image/gif", "image/webp", "text/plain", "application/json", "text/yaml", "text/markdown", "text/csv", "application/xml", "text/x-log", ]; 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 { projectId?: string; onClose: () => void; onCreate: (input: { agentId: string; modelProvider?: string; modelId?: string }) => void; } function NewChatDialog({ projectId, 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(""); const [favoriteProviders, setFavoriteProviders] = useState([]); const [favoriteModels, setFavoriteModels] = useState([]); // Load agents on mount (project-scoped) useEffect(() => { let cancelled = false; setAgentsLoading(true); fetchAgents(undefined, projectId) .then((response) => { if (!cancelled) { setAgents(response); } }) .catch(() => { if (!cancelled) { // Silently fail - show empty list setAgents([]); } }) .finally(() => { if (!cancelled) { setAgentsLoading(false); } }); return () => { cancelled = true; }; }, [projectId]); // Load models on mount useEffect(() => { setModelsLoading(true); fetchModels() .then((response) => { setModels(response.models); setFavoriteProviders(response.favoriteProviders); setFavoriteModels(response.favoriteModels); }) .catch(() => { // Silently fail - show empty list setModels([]); setFavoriteProviders([]); setFavoriteModels([]); }) .finally(() => { setModelsLoading(false); }); }, []); const handleToggleFavorite = useCallback(async (provider: string) => { const currentFavorites = favoriteProviders; const isFavorite = currentFavorites.includes(provider); const newFavorites = isFavorite ? currentFavorites.filter((value) => value !== provider) : [provider, ...currentFavorites]; setFavoriteProviders(newFavorites); try { await updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels }); } catch { setFavoriteProviders(currentFavorites); } }, [favoriteProviders, favoriteModels]); const handleToggleModelFavorite = useCallback(async (modelId: string) => { const currentFavorites = favoriteModels; const isFavorite = currentFavorites.includes(modelId); const newFavorites = isFavorite ? currentFavorites.filter((value) => value !== modelId) : [modelId, ...currentFavorites]; setFavoriteModels(newFavorites); try { await updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites }); } catch { setFavoriteModels(currentFavorites); } }, [favoriteModels, favoriteProviders]); const handleSubmit = (e: React.SyntheticEvent) => { 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: FN_AGENT_ID, modelProvider, modelId }); }; const isSubmitDisabled = chatMode === "agent" ? !selectedAgentId : !selectedModel; return (
e.stopPropagation()}>

New Chat

{chatMode === "agent" && ( )} {chatMode === "model" && (
{modelsLoading ? (
Loading models...
) : ( )}
)}
); } interface ChatMessageItemProps { message: ChatMessageInfo; forcePlain: boolean; agentName: string; showAssistantModelTag: boolean; activeModelTag: string | null; activeSessionId: string | null; mentionAgentsByName: Map; onToggleRender: (id: string) => void; } // Renders a single chat message bubble. Memoized so the streaming bubble's // per-frame state churn does not re-render every prior message (each one // would re-run ReactMarkdown over its full content otherwise). const ChatMessageItem = memo(function ChatMessageItem({ message, forcePlain, agentName, showAssistantModelTag, activeModelTag, activeSessionId, mentionAgentsByName, onToggleRender, }: ChatMessageItemProps) { const isAssistantMessage = message.role === "assistant"; const renderedUserContent = useMemo(() => { if (isAssistantMessage) return null; const content = message.content; 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)); return parts.length === 0 ? content : parts; }, [isAssistantMessage, message.content, mentionAgentsByName]); const renderedAttachments = useMemo(() => { const attachments = message.attachments; if (!attachments || attachments.length === 0 || !activeSessionId) return null; const attachmentUrlBase = `/api/chat/sessions/${encodeURIComponent(activeSessionId)}/attachments/`; return (
{attachments.map((attachment) => { const isImage = attachment.mimeType.startsWith("image/"); const key = attachment.id || attachment.filename; const href = `${attachmentUrlBase}${encodeURIComponent(attachment.filename)}`; if (isImage) { return ( {attachment.originalName} ); } return ( {attachment.originalName} ); })}
); }, [message.attachments, activeSessionId]); const assistantBody = useMemo(() => { if (!isAssistantMessage) return null; if (forcePlain) { return
{message.content}
; } return (
{message.content}
); }, [isAssistantMessage, forcePlain, message.content]); return (
{isAssistantMessage && (
{agentName} {showAssistantModelTag && activeModelTag && {activeModelTag}}
)} {isAssistantMessage ? assistantBody :
{renderedUserContent}
} {renderToolCalls(message.toolCalls)} {message.thinkingOutput && (
Thinking
{message.thinkingOutput}
)} {renderedAttachments}
{formatRelativeTime(message.createdAt)}
); }); export function ChatView({ projectId, addToast }: ChatViewProps) { const { activeSession, sessionsLoading, messages, messagesLoading, isStreaming, streamingText, streamingThinking, streamingToolCalls, selectSession, createSession, archiveSession, deleteSession, sendMessage, stopStreaming, pendingMessage, clearPendingMessage, searchQuery, setSearchQuery, filteredSessions, } = useChat(projectId, addToast); 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 [sidebarWidth, setSidebarWidth] = useState(CHAT_SIDEBAR_DEFAULT_WIDTH); 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 [plainTextMessageIds, setPlainTextMessageIds] = useState>(() => new Set()); // Attachment state mirrors QuickEntryBox: pending files selected before send. const [pendingAttachments, setPendingAttachments] = useState([]); const [isDragOver, setIsDragOver] = useState(false); // File mention state and hook const [, setFileMentionPopupVisible] = useState(false); const [fileMentionPosition, setFileMentionPosition] = useState({ top: 0, left: 0 }); const fileMention = useFileMention({ projectId }); // Calculate popup position based on caret position in textarea const updateFileMentionPosition = useCallback((textarea: HTMLTextAreaElement | null) => { if (!textarea || !fileMention.mentionActive) return; // Get textarea position const rect = textarea.getBoundingClientRect(); // Position above the textarea, using viewport coordinates // The popup is absolutely positioned, so we use window coordinates setFileMentionPosition({ top: rect.top - 260, // Popup appears above with gap (accounting for popup height) left: rect.left + 8, // Small left offset }); }, [fileMention.mentionActive]); const messagesEndRef = useRef(null); const hideSkillMenuTimeoutRef = useRef(null); const messagesContainerRef = useRef(null); const inputRef = useRef(null); const preserveComposerFocusRef = useRef(false); const handledMobileSendRef = useRef(false); const fileInputRef = useRef(null); const pendingAttachmentsRef = useRef([]); const mentionCursorPosRef = useRef(0); const mode = useViewportMode(); const isMobile = mode === "mobile"; useEffect(() => { try { const rawWidth = localStorage.getItem(CHAT_SIDEBAR_STORAGE_KEY); if (!rawWidth) return; const parsedWidth = Number.parseInt(rawWidth, 10); if (Number.isNaN(parsedWidth)) return; const clampedWidth = Math.max(CHAT_SIDEBAR_MIN_WIDTH, Math.min(CHAT_SIDEBAR_MAX_WIDTH, parsedWidth)); setSidebarWidth(clampedWidth); } catch { // Ignore storage errors. } }, []); const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: isMobile && !!activeSession, }); const threadKeyboardStyle: CSSProperties = keyboardOpen ? ({ "--keyboard-overlap": `${keyboardOverlap}px`, "--vv-offset-top": `${viewportOffsetTop}px`, ...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}), } as CSSProperties) : {}; 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 thread container to bottom on new messages or streaming. // Avoid Element.scrollIntoView() here because on mobile Safari it can // scroll the page viewport instead of only the chat thread. useEffect(() => { const messagesContainer = messagesContainerRef.current; if (!messagesContainer) return; messagesContainer.scrollTop = messagesContainer.scrollHeight; }, [messages, streamingText, streamingThinking, isStreaming]); useEffect(() => { if (keyboardOverlap <= 0) { return; } const messagesContainer = messagesContainerRef.current; if (!messagesContainer) { return; } messagesContainer.scrollTop = messagesContainer.scrollHeight; }, [keyboardOverlap]); // Lock body scroll on mobile while the keyboard is up so iOS can't shift // the visual viewport (offsetTop > 0). Shared hook also restores // window.scrollTo(0, 0) on cleanup to recover from any iOS drift. useMobileScrollLock(isMobile && keyboardOpen); // 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 (project-scoped with stale-request protection) useEffect(() => { let cancelled = false; const currentProjectId = projectId; fetchAgents(undefined, projectId) .then((agents) => { // Ignore response if project changed during fetch if (cancelled || currentProjectId !== projectId) return; const map = new Map(); for (const agent of agents) { map.set(agent.id, agent); } setAgentsMap(map); }) .catch(() => { // Silently fail - keep empty map }); return () => { cancelled = true; }; }, [projectId]); // 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]); useEffect(() => { pendingAttachmentsRef.current = pendingAttachments; }, [pendingAttachments]); useEffect(() => { return () => { for (const attachment of pendingAttachmentsRef.current) { if (attachment.previewUrl) { URL.revokeObjectURL(attachment.previewUrl); } } }; }, []); const handleAttachmentFiles = useCallback((files: FileList | File[] | null | undefined) => { if (!files || files.length === 0) return; const nextAttachments: PendingAttachment[] = []; for (const file of Array.from(files)) { if (!ALLOWED_ATTACHMENT_TYPES.includes(file.type)) { continue; } const isImage = file.type.startsWith("image/"); nextAttachments.push({ file, previewUrl: isImage ? URL.createObjectURL(file) : "", }); } if (nextAttachments.length > 0) { setPendingAttachments((prev) => [...prev, ...nextAttachments]); } }, []); const removeAttachment = useCallback((index: number) => { setPendingAttachments((prev) => { const attachment = prev[index]; if (attachment?.previewUrl) { URL.revokeObjectURL(attachment.previewUrl); } return prev.filter((_, attachmentIndex) => attachmentIndex !== index); }); }, []); const handlePaste = useCallback((event: React.ClipboardEvent) => { const clipboardFiles = event.clipboardData?.files; if (!clipboardFiles || clipboardFiles.length === 0) return; const imageFiles = Array.from(clipboardFiles).filter((file) => file.type.startsWith("image/")); if (imageFiles.length === 0) return; handleAttachmentFiles(imageFiles); }, [handleAttachmentFiles]); // 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], ); const clearComposerState = useCallback(() => { setMessageInput(""); setShowSkillMenu(false); setSkillFilter(""); setMentionPopupVisible(false); setMentionFilter(""); setMentionStartPos(-1); setPendingAttachments((prev) => { for (const attachment of prev) { if (attachment.previewUrl) { URL.revokeObjectURL(attachment.previewUrl); } } return []; }); }, []); // Handle send message including pending attachment uploads. const handleSend = useCallback(() => { const trimmed = messageInput.trim(); const files = pendingAttachments.map((attachment) => attachment.file); if ((!trimmed && files.length === 0) || !activeSession) return; if (trimmed === "/clear") { clearComposerState(); stopStreaming(); clearPendingMessage(); void createSession({ agentId: activeSession.agentId, modelProvider: activeSession.modelProvider ?? undefined, modelId: activeSession.modelId ?? undefined, }).catch(() => { addToast("Failed to clear conversation", "error"); }); return; } clearComposerState(); sendMessage(trimmed, files); }, [ messageInput, pendingAttachments, activeSession, clearComposerState, stopStreaming, clearPendingMessage, createSession, addToast, sendMessage, ]); const focusComposerInput = useCallback(() => { if (typeof window === "undefined") return; if (window.innerWidth > 768) return; const input = inputRef.current; if (!input || input.disabled) return; const previousScrollX = window.scrollX; const previousScrollY = window.scrollY; input.focus({ preventScroll: true }); // iOS can still jump layout viewport on focus changes even with preventScroll. // Restore scroll position on the next frame to keep the thread anchored. window.requestAnimationFrame(() => { if (window.scrollX !== previousScrollX || window.scrollY !== previousScrollY) { window.scrollTo(previousScrollX, previousScrollY); } }); }, []); const markPreserveComposerFocus = useCallback(() => { if (typeof window === "undefined") return; if (window.innerWidth > 768) return; preserveComposerFocusRef.current = true; }, []); 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], ); // Handle input key down const handleInputKeyDown = useCallback( (e: React.KeyboardEvent) => { mentionCursorPosRef.current = e.currentTarget.selectionStart ?? mentionCursorPosRef.current; // Handle file mention popup keyboard navigation first if (fileMention.mentionActive && fileMention.files.length > 0) { fileMention.handleKeyDown(e, messageInput); if (e.key === "Enter" || e.key === "Tab") { // Select the highlighted file const file = fileMention.files[fileMention.selectedIndex]; if (file) { const newText = fileMention.selectFile(file, messageInput); setMessageInput(newText); fileMention.dismissMention(); setFileMentionPopupVisible(false); } } return; } 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, fileMention, messageInput, ], ); 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); // Detect file mentions fileMention.detectMention(nextValue, cursorPos); setFileMentionPopupVisible(fileMention.mentionActive); if (fileMention.mentionActive) { updateFileMentionPosition(textarea); } 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); // Detect file mentions fileMention.detectMention(textarea.value, cursorPos); setFileMentionPopupVisible(fileMention.mentionActive); if (fileMention.mentionActive) { updateFileMentionPosition(textarea); } }, [updateMentionState, fileMention, updateFileMentionPosition], ); const handleInputKeyUp = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape") { return; } handleInputSelectionChange(e); }, [handleInputSelectionChange], ); const handleInputBlur = useCallback(() => { if (preserveComposerFocusRef.current) { window.requestAnimationFrame(() => { focusComposerInput(); }); return; } if (hideSkillMenuTimeoutRef.current !== null) { window.clearTimeout(hideSkillMenuTimeoutRef.current); } hideSkillMenuTimeoutRef.current = window.setTimeout(() => { setShowSkillMenu(false); setMentionPopupVisible(false); setMentionFilter(""); setMentionStartPos(-1); setFileMentionPopupVisible(false); fileMention.dismissMention(); hideSkillMenuTimeoutRef.current = null; }, 120); }, [fileMention, focusComposerInput]); 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], ); const persistSidebarWidth = useCallback((width: number) => { try { localStorage.setItem(CHAT_SIDEBAR_STORAGE_KEY, String(width)); } catch { // Ignore storage errors. } }, []); const handleResizeStart = useCallback((event: React.PointerEvent) => { if (isMobile) { return; } event.preventDefault(); event.stopPropagation(); const resizeHandle = event.currentTarget; if (typeof resizeHandle.setPointerCapture === "function") { resizeHandle.setPointerCapture(event.pointerId); } const startX = event.clientX; const startWidth = sidebarWidth; let latestWidth = startWidth; document.body.style.userSelect = "none"; const onPointerMove = (moveEvent: PointerEvent) => { const deltaX = moveEvent.clientX - startX; const nextWidth = Math.max(CHAT_SIDEBAR_MIN_WIDTH, Math.min(CHAT_SIDEBAR_MAX_WIDTH, startWidth + deltaX)); latestWidth = nextWidth; setSidebarWidth(nextWidth); persistSidebarWidth(nextWidth); }; const onPointerUp = (upEvent: PointerEvent) => { if (typeof resizeHandle.releasePointerCapture === "function") { resizeHandle.releasePointerCapture(upEvent.pointerId); } document.body.style.userSelect = ""; document.removeEventListener("pointermove", onPointerMove); document.removeEventListener("pointerup", onPointerUp); persistSidebarWidth(latestWidth); }; document.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerup", onPointerUp); }, [isMobile, persistSidebarWidth, sidebarWidth]); const handleResizeKeyDown = useCallback((event: React.KeyboardEvent) => { if (isMobile) { return; } if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") { return; } event.preventDefault(); const step = event.shiftKey ? 50 : 10; const delta = event.key === "ArrowLeft" ? -step : step; const nextWidth = Math.max(CHAT_SIDEBAR_MIN_WIDTH, Math.min(CHAT_SIDEBAR_MAX_WIDTH, sidebarWidth + delta)); setSidebarWidth(nextWidth); persistSidebarWidth(nextWidth); }, [isMobile, persistSidebarWidth, sidebarWidth]); // 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 = () => { return (

Start a new conversation

); }; const activeModelTag = formatModelTag(activeSession?.modelProvider, activeSession?.modelId); const threadHeaderTitle = activeSession?.agentId === FN_AGENT_ID ? (activeModelTag ?? "Fusion") : activeSession?.title || agentsMap.get(activeSession?.agentId ?? "")?.name || activeSession?.agentId || "Chat"; const showThreadHeaderModelTag = Boolean(activeModelTag && activeModelTag !== threadHeaderTitle); const agentName = agentsMap.get(activeSession?.agentId ?? "")?.name || (activeSession?.agentId === FN_AGENT_ID ? (activeModelTag ?? "Fusion") : (activeSession?.agentId?.slice(0, 30) ?? "Fusion")); const showAssistantModelTag = Boolean(activeModelTag && activeModelTag !== agentName); const pendingPreview = pendingMessage.length > 50 ? `${pendingMessage.slice(0, 50)}…` : pendingMessage; const toggleMessageRenderMode = useCallback((messageId: string) => { setPlainTextMessageIds((current) => { const next = new Set(current); if (next.has(messageId)) { next.delete(messageId); } else { next.add(messageId); } return next; }); }, []); const renderAssistantContent = useCallback( (content: string, forcePlain = false) => { const showPlainText = forcePlain; if (showPlainText) { return
{content}
; } return (
{content}
); }, [], ); return (
{/* Sidebar */}
{/* Search section */}
setSearchQuery(e.target.value)} data-testid="chat-search-input" />
{/* Session list section */}
{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 === FN_AGENT_ID ? (formatModelTag(session.modelProvider, session.modelId) ?? "Fusion") : session.agentId.slice(0, 30))} {session.updatedAt ? formatRelativeTime(session.updatedAt) : ""}
)) )}
{/* Mobile footer with New Chat action */}
{!isMobile && sidebarVisible && (
)} {/* 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 - always rendered in desktop/tablet, only rendered in mobile when viewing a thread */} {(activeSession || !isMobile) && (
{isMobile && activeSession && ( )} {threadHeaderTitle} {showThreadHeaderModelTag && {activeModelTag}} {!isMobile && ( )}
)} {/* Messages */}
{isStreaming ? ( <> {messages.map((message) => ( ))}
{agentName} {showAssistantModelTag && {activeModelTag}}
{streamingText ? ( renderAssistantContent(streamingText, plainTextMessageIds.has("__streaming__")) ) : (
{streamingThinking ? "Thinking…" : "Connecting…"}
)} {renderToolCalls(streamingToolCalls)} {streamingThinking && (
Thinking
{streamingThinking}
)}
) : messagesLoading ? (
Loading messages...
) : messages.length === 0 && !activeSession ? ( renderEmptyState() ) : messages.length === 0 && activeSession ? (
No messages yet. Start the conversation!
) : ( <> {messages.map((message) => ( ))} )}
{/* Input */} {activeSession && (
{ handleAttachmentFiles(event.target.files); event.target.value = ""; }} /> {showSkillMenu && (
{skillsLoading ? (
Loading skills…
) : filteredSkills.length === 0 ? (
{skillFilter ? "No skills found" : "No skills available"}
) : ( filteredSkills.map((skill, index) => ( )) )}
)} {pendingAttachments.length > 0 && (
{pendingAttachments.map((attachment, index) => (
{attachment.previewUrl ? ( {attachment.file.name} ) : ( {attachment.file.name} )}
))}
)}
{ event.preventDefault(); setIsDragOver(true); }} onDragLeave={() => setIsDragOver(false)} onDrop={(event) => { event.preventDefault(); setIsDragOver(false); handleAttachmentFiles(event.dataTransfer.files); }} >