// 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, ChevronDown, Copy, Check, } 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 { ProviderIcon } from "./ProviderIcon"; 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...
) : ( )}
)}
); } type CopyFeedbackState = "success" | "error" | null; interface ChatMessageItemProps { message: ChatMessageInfo; /** * When true, render assistant message content as plain text instead of * Markdown. The per-message eye toggle has been removed in favor of a * single thread-level toggle in the chat header, so this is a global * mirror of that header state. */ forcePlain: boolean; agentName: string; /** * Hide the per-message agent identity (icon + name + model tag) on * assistant bubbles. In model-only chats the agent identity *is* the * active model and it's already shown in the thread header. */ hideAssistantIdentity: boolean; showAssistantModelTag: boolean; activeModelTag: string | null; activeModelProvider: string | null; activeSessionId: string | null; mentionAgentsByName: Map; copyAction?: ReactNode; } // 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, hideAssistantIdentity, showAssistantModelTag, activeModelTag, activeModelProvider, activeSessionId, mentionAgentsByName, copyAction, }: 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 && !hideAssistantIdentity && (
{activeModelProvider ? : } {agentName} {showAssistantModelTag && activeModelTag && {activeModelTag}}
)} {isAssistantMessage ? assistantBody :
{renderedUserContent}
} {copyAction} {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); // Single thread-wide toggle: when true, all assistant content (including the // streaming bubble) renders as plain text instead of Markdown. Replaces the // earlier per-message toggle so the chat header owns this control instead // of every reply having its own button. const [showAllAsPlain, setShowAllAsPlain] = useState(false); // Attachment state mirrors QuickEntryBox: pending files selected before send. const [pendingAttachments, setPendingAttachments] = useState([]); const [isDragOver, setIsDragOver] = useState(false); const [isUserScrolling, setIsUserScrolling] = useState(false); const [copyFeedbackByMessageId, setCopyFeedbackByMessageId] = useState>({}); // 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 isUserScrollingRef = useRef(false); const hideSkillMenuTimeoutRef = useRef(null); const messagesContainerRef = useRef(null); const inputRef = useRef(null); const fileInputRef = useRef(null); const pendingAttachmentsRef = useRef([]); const mentionCursorPosRef = useRef(0); const copyFeedbackTimeoutsRef = useRef>(new Map()); 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, }); // Only opt into visual-viewport sizing when we have concrete keyboard // displacement (overlap or offset). The shared hook can report `keyboardOpen` // during iOS settle/shrink phases with zero overlap; forcing vv-height in that // transient state shrinks the thread and pushes the composer upward. const hasKeyboardViewportDisplacement = keyboardOverlap > 0 || viewportOffsetTop > 0; const threadKeyboardStyle: CSSProperties = keyboardOpen && hasKeyboardViewportDisplacement ? ({ "--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); } }; }, []); const updateScrollState = useCallback(() => { const messagesContainer = messagesContainerRef.current; if (!messagesContainer) return; const threshold = 50; const atBottom = messagesContainer.scrollTop + messagesContainer.clientHeight >= messagesContainer.scrollHeight - threshold; setIsUserScrolling(!atBottom); isUserScrollingRef.current = !atBottom; }, []); const scrollToBottom = useCallback(() => { const messagesContainer = messagesContainerRef.current; if (!messagesContainer) return; messagesContainer.scrollTop = messagesContainer.scrollHeight; setIsUserScrolling(false); isUserScrollingRef.current = false; }, []); // Scroll thread container to bottom on new messages or streaming when user is near live tail. // Avoid Element.scrollIntoView() here because on mobile Safari it can // scroll the page viewport instead of only the chat thread. useEffect(() => { if (!isUserScrollingRef.current) { scrollToBottom(); } }, [messages, streamingText, streamingThinking, isStreaming, scrollToBottom]); useEffect(() => { if (keyboardOverlap <= 0) { return; } const messagesContainer = messagesContainerRef.current; if (!messagesContainer) { return; } scrollToBottom(); }, [keyboardOverlap, scrollToBottom]); // 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]); // While the keyboard is up on mobile, block touchmove gestures that // would otherwise pan the iOS visualViewport (or scroll the document) // and let the composer / header drift. We attach a non-passive listener // to document so that gestures starting anywhere — header, composer // padding, body — are cancelled. The exception is when the touch path // crosses the messages list, which is the one place we DO want pan-y. // useMobileScrollLock only pins document scroll; this complements it // by stopping vv pan on top of the locked layout. // React's synthetic onTouchMove is passive by default, so this has to // be a native addEventListener with { passive: false }. useEffect(() => { if (!isMobile || !keyboardOpen) return; const onTouchMove = (event: TouchEvent) => { const target = event.target as Element | null; if (target?.closest(".chat-messages")) return; // allow messages scroll event.preventDefault(); }; document.addEventListener("touchmove", onTouchMove, { passive: false }); return () => { document.removeEventListener("touchmove", onTouchMove); }; }, [isMobile, keyboardOpen]); // On mount and on visibility/page restore, if iOS thinks the keyboard is // up but the textarea isn't actually focused (or vice versa), the // visualViewport metrics get stuck in a half-state — composer pushed up // or covered by a blank pane. Force a blur+refocus on the textarea to // make iOS resync. Only runs on mobile and only when ChatView holds the // active session (avoids stealing focus from other views). useEffect(() => { if (!isMobile || !activeSession) return; const resync = () => { const ta = inputRef.current; if (!ta) return; if (document.activeElement !== ta) return; // only if it was focused ta.blur(); window.setTimeout(() => { ta.focus({ preventScroll: true }); }, 0); }; document.addEventListener("visibilitychange", resync); window.addEventListener("pageshow", resync); return () => { document.removeEventListener("visibilitychange", resync); window.removeEventListener("pageshow", resync); }; }, [isMobile, activeSession]); // 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); } } for (const timeoutId of copyFeedbackTimeoutsRef.current.values()) { window.clearTimeout(timeoutId); } copyFeedbackTimeoutsRef.current.clear(); }; }, []); 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 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 (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]); const handleInputFocus = useCallback(() => { if (hideSkillMenuTimeoutRef.current !== null) { window.clearTimeout(hideSkillMenuTimeoutRef.current); hideSkillMenuTimeoutRef.current = null; } // iOS quirk: after the keyboard has been dismissed once, re-focusing // an input leaves window.scrollY > 0 *and* visualViewport.offsetTop // > 0 — the layout viewport drifts up, and the position:fixed // useMobileScrollLock applies to a body that is no longer at the // top of the document. Result: the message thread anchors above // the visible viewport with a large blank area below it. Forcing // scroll back to (0,0) on the focus event neutralizes the drift // before lock applies. Done in a microtask so iOS finishes its // own scroll-into-view first. if (typeof window !== "undefined" && window.innerWidth <= 768) { window.setTimeout(() => { if (window.scrollY !== 0 || window.scrollX !== 0) { window.scrollTo(0, 0); } }, 0); } }, []); // 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 activeModelProvider = activeSession?.modelProvider ?? null; const hasThreadInView = Boolean(activeSession || isStreaming || messages.length > 0); 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")); // The model tag is already visible in the thread header — repeating it on // every assistant message is noise. Keep it suppressed for regular chat // (real agent name is the identity); QuickChat already collapses the tag // because its `agentName` IS the model tag, so the per-message slot was // always empty there too. const showAssistantModelTag = false; // In model-only chats (no real agent picked) the agent identity *is* the // model name, which is already in the thread header. Repeating it on every // assistant bubble is noise. Hide the per-message identity row entirely; // the render-mode toggle still appears in a slim toolbar. const hideAssistantIdentity = activeSession?.agentId === FN_AGENT_ID; const pendingPreview = pendingMessage.length > 50 ? `${pendingMessage.slice(0, 50)}…` : pendingMessage; const toggleAllAsPlain = useCallback(() => { setShowAllAsPlain((value) => !value); }, []); const setCopyFeedback = useCallback((messageId: string, feedback: CopyFeedbackState) => { const existingTimeout = copyFeedbackTimeoutsRef.current.get(messageId); if (existingTimeout) { window.clearTimeout(existingTimeout); } setCopyFeedbackByMessageId((current) => ({ ...current, [messageId]: feedback })); const timeoutId = window.setTimeout(() => { setCopyFeedbackByMessageId((current) => { const { [messageId]: _removed, ...rest } = current; return rest; }); copyFeedbackTimeoutsRef.current.delete(messageId); }, 2000); copyFeedbackTimeoutsRef.current.set(messageId, timeoutId); }, []); const handleCopyResponse = useCallback(async (messageId: string, content: string) => { try { if (!navigator.clipboard?.writeText) { throw new Error("Clipboard API unavailable"); } await navigator.clipboard.writeText(content); setCopyFeedback(messageId, "success"); } catch { setCopyFeedback(messageId, "error"); } }, [setCopyFeedback]); const renderAssistantContent = useCallback( (content: string, forcePlain = false) => { const showPlainText = forcePlain; if (showPlainText) { return
{content}
; } return (
{content}
); }, [], ); const showProviderResponseCopy = activeSession?.agentId === FN_AGENT_ID; const renderCopyAction = useCallback((messageId: string, content: string, testId?: string) => ( ), [copyFeedbackByMessageId, handleCopyResponse]); 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"}
{session.modelProvider && ( )} {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 */} {(hasThreadInView || !isMobile) && (
{isMobile && hasThreadInView && ( )}
{activeModelProvider ? : } {threadHeaderTitle} {showThreadHeaderModelTag && {activeModelTag}}
{hasThreadInView && ( )} {!isMobile && ( )}
)} {/* Messages */}
{isStreaming ? ( <> {messages.map((message) => ( ))}
{!hideAssistantIdentity && (
{activeModelProvider ? : } {agentName} {showAssistantModelTag && {activeModelTag}}
)} {streamingText ? ( renderAssistantContent(streamingText, showAllAsPlain) ) : (
{streamingThinking ? "Thinking…" : "Connecting…"}
)} {showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")} {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) => ( ))} )}
{isUserScrolling && ( )} {/* 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); }} >