// 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 React, { memo, useCallback, useEffect, useLayoutEffect, 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, TriangleAlert, } from "lucide-react"; import { useChat, type ChatMessageInfo, type FailureInfo, type ToolCallInfo } from "../hooks/useChat"; import { useChatRooms } from "../hooks/useChatRooms"; import { useChatUnread } from "../hooks/useChatUnread"; 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 { AgentAvatar } from "./AgentAvatar"; import { FileMentionPopup } from "./FileMentionPopup"; import { CreateRoomModal } from "./CreateRoomModal"; import { useFileMention } from "../hooks/useFileMention"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { matchesAgentMentionFilter } from "./mentionMatching"; import { useNavigationHistoryContext } from "../hooks/useNavigationHistory"; import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify"; export interface ChatViewProps { projectId?: string; addToast: (msg: string, type?: "success" | "error" | "warning") => void; experimentalFeatures?: Record; } const CHAT_INPUT_MAX_HEIGHT_PX = 320; export function clampChatInputHeight(scrollHeight: number): number { return Math.min(scrollHeight, CHAT_INPUT_MAX_HEIGHT_PX); } 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 buildFailureReferenceHref(reference: FailureInfo["reference"]): string | null { if (!reference) { return null; } if (reference.kind === "mailbox" || reference.kind === "mailbox-message") { const pathname = typeof window === "undefined" ? "/" : window.location.pathname || "/"; const params = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search); params.set("view", "mailbox"); params.set("mailbox-message", reference.id); return `${pathname}?${params.toString()}#message-${encodeURIComponent(reference.id)}`; } return null; } function renderFailureReference(reference: FailureInfo["reference"]): ReactNode { if (!reference) { return null; } const referenceLabel = reference.label ?? `${reference.kind} ${reference.id}`; const referenceHref = buildFailureReferenceHref(reference); const referenceDetailsId = `chat-failure-reference-${reference.kind}-${reference.id}` .replace(/[^a-zA-Z0-9_-]+/g, "-") .toLowerCase(); return (
Reference {referenceLabel} {referenceHref ? ( Open mailbox message ) : (
View failure details
Kind
{reference.kind}
ID
{reference.id}
{reference.label && (
Label
{reference.label}
)}
)}
); } 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 = { p: ({ children, ...props }) => (

{linkifyReactChildren(children)}

), li: ({ children, ...props }) => (
  • {linkifyReactChildren(children)}
  • ), pre: ({ children, ...props }) => (
          {children}
        
    ), code: ({ children, ...props }) => { const text = typeof children === "string" ? children : React.Children.toArray(children).join(""); const linkedChildren = linkifyFilePaths(text); if (linkedChildren.length === 1 && typeof linkedChildren[0] === "string") { return {children}; } return {linkedChildren}; }, 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"; const CHAT_SCOPE_STORAGE_KEY = "fusion:chat-scope"; const CHAT_DRAFT_STORAGE_PREFIX = "fusion:chat-draft:"; function getChatDraftKey(scope: "direct" | "rooms", id: string | null | undefined): string | null { if (!id) { return null; } return `${CHAT_DRAFT_STORAGE_PREFIX}${scope}:${id}`; } function getPersistedChatDraft(key: string | null): string { if (!key) { return ""; } try { return localStorage.getItem(key) ?? ""; } catch { return ""; } } 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 RoomContext { roomId: string; roomName: string; memberIds: ReadonlySet; } 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; roomContext: RoomContext | null; 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, roomContext, copyAction, }: ChatMessageItemProps) { const isAssistantMessage = message.role === "assistant"; const failureInfo = isAssistantMessage ? message.failureInfo : undefined; const showAssistantIdentity = isAssistantMessage && (!hideAssistantIdentity || Boolean(failureInfo)); 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) { const isNonMember = Boolean(roomContext && !roomContext.memberIds.has(mentionedAgent.id)); const nonMemberLabel = isNonMember ? `Not a member of ${roomContext?.roomName}` : undefined; 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, roomContext]); const renderedAttachments = useMemo(() => { const attachments = message.attachments; if (!attachments || attachments.length === 0) return null; const attachmentUrlBase = message.roomId ? `/api/chat/rooms/${encodeURIComponent(message.roomId)}/attachments/` : (activeSessionId ? `/api/chat/sessions/${encodeURIComponent(activeSessionId)}/attachments/` : null); if (!attachmentUrlBase) return null; 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, message.roomId, activeSessionId]); const assistantBody = useMemo(() => { if (!isAssistantMessage) return null; if (failureInfo) { return (
    {failureInfo.summary}
    {(failureInfo.errorClass || failureInfo.code) && (
    {failureInfo.errorClass && {failureInfo.errorClass}} {failureInfo.code && {failureInfo.code}}
    )} {(failureInfo.detail || failureInfo.reference) && (
    {failureInfo.detail &&
    {linkifyFilePaths(failureInfo.detail)}
    } {renderFailureReference(failureInfo.reference)}
    )}
    ); } if (forcePlain) { return
    {message.content}
    ; } return (
    {message.content}
    ); }, [failureInfo, forcePlain, isAssistantMessage, message.content]); return (
    {showAssistantIdentity && (
    {activeModelProvider ? : } {agentName} {showAssistantModelTag && activeModelTag && {activeModelTag}}
    )} {isAssistantMessage ? assistantBody :
    {renderedUserContent}
    } {!failureInfo && copyAction} {renderToolCalls(message.toolCalls)} {message.thinkingOutput && (
    Thinking
    {linkifyFilePaths(message.thinkingOutput)}
    )} {renderedAttachments}
    {formatRelativeTime(message.createdAt)}
    ); }); export function ChatView({ projectId, addToast, experimentalFeatures }: 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 chatRoomsEnabled = experimentalFeatures?.chatRooms === true; const [chatScope, setChatScope] = useState<"direct" | "rooms">(() => { try { const persistedScope = localStorage.getItem(CHAT_SCOPE_STORAGE_KEY); if (persistedScope === "rooms" && chatRoomsEnabled) { return "rooms"; } } catch { // Ignore storage errors. } return "direct"; }); // Keep this hook unconditional to preserve hook ordering and test stability. // Rooms UI and interactions are fully gated by `chatRoomsEnabled`. const rooms = useChatRooms(projectId, addToast); const { isUnread, markRead } = useChatUnread(projectId); const [messageInput, setMessageInput] = useState(() => { const initialDraftKey = getChatDraftKey( chatScope, chatScope === "rooms" ? rooms.activeRoom?.id : activeSession?.id, ); return getPersistedChatDraft(initialDraftKey); }); const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null); const [confirmDelete, setConfirmDelete] = useState(null); const [confirmDeleteRoomId, setConfirmDeleteRoomId] = useState(null); const [sidebarVisible, setSidebarVisible] = useState(true); const [sidebarWidth, setSidebarWidth] = useState(CHAT_SIDEBAR_DEFAULT_WIDTH); const [createRoomOpen, setCreateRoomOpen] = useState(false); 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>({}); const [mobileSessionMenuOpen, setMobileSessionMenuOpen] = useState(false); const [roomSwitcherOpen, setRoomSwitcherOpen] = useState(false); const { pushNav } = useNavigationHistoryContext(); // 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 mobileSessionMenuRef = useRef(null); const roomSwitcherRef = useRef(null); const isUserScrollingRef = useRef(false); const lastAnchoredThreadStateRef = useRef<{ threadId: string; loaded: boolean; hasMessages: boolean } | null>(null); const previousChatScopeRef = useRef<"direct" | "rooms" | null>(null); const visibilityReanchorTimeoutRef = useRef(null); const directThreadDeferredAnchorTimeoutRef = useRef(null); 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(() => { if (!activeSession?.id) { return; } markRead("direct", activeSession.id, activeSession.lastMessageAt ?? activeSession.updatedAt); }, [activeSession?.id, activeSession?.lastMessageAt, activeSession?.updatedAt, markRead]); useEffect(() => { if (!rooms.activeRoom?.id) { return; } markRead("room", rooms.activeRoom.id, rooms.activeRoom.updatedAt); }, [rooms.activeRoom?.id, rooms.activeRoom?.updatedAt, markRead]); useEffect(() => { if (!activeSession?.id || messages.length === 0) { return; } const latestMessage = messages[messages.length - 1]; markRead("direct", activeSession.id, latestMessage?.createdAt ?? activeSession.lastMessageAt ?? activeSession.updatedAt); }, [activeSession?.id, activeSession?.lastMessageAt, activeSession?.updatedAt, markRead, messages]); useEffect(() => { if (!rooms.activeRoom?.id || rooms.messages.length === 0) { return; } const latestMessage = rooms.messages[rooms.messages.length - 1]; markRead("room", rooms.activeRoom.id, latestMessage?.createdAt ?? rooms.activeRoom.updatedAt); }, [markRead, rooms.activeRoom?.id, rooms.activeRoom?.updatedAt, rooms.messages]); 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. } }, []); useEffect(() => { try { const persistedScope = localStorage.getItem(CHAT_SCOPE_STORAGE_KEY); if (persistedScope === "direct") { setChatScope("direct"); return; } if (persistedScope === "rooms" && chatRoomsEnabled) { setChatScope("rooms"); } } catch { // Ignore storage errors. } }, [chatRoomsEnabled]); useEffect(() => { if (!chatRoomsEnabled && chatScope === "rooms") { setChatScope("direct"); return; } try { localStorage.setItem(CHAT_SCOPE_STORAGE_KEY, chatScope); } catch { // Ignore storage errors. } }, [chatRoomsEnabled, chatScope]); const activeDraftKey = getChatDraftKey( chatScope, chatScope === "rooms" ? rooms.activeRoom?.id : activeSession?.id, ); const lastDraftKeyRef = useRef(activeDraftKey); useEffect(() => { if (activeDraftKey === lastDraftKeyRef.current) { return; } lastDraftKeyRef.current = activeDraftKey; setMessageInput(getPersistedChatDraft(activeDraftKey)); }, [activeDraftKey]); useEffect(() => { if (!activeDraftKey || lastDraftKeyRef.current !== activeDraftKey) { return; } try { if (messageInput) { localStorage.setItem(activeDraftKey, messageInput); return; } localStorage.removeItem(activeDraftKey); } catch { // Ignore storage errors. } }, [activeDraftKey, messageInput]); const roomThreadActive = chatRoomsEnabled && chatScope === "rooms" && !!rooms.activeRoom; const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: isMobile && (!!activeSession || roomThreadActive), }); // 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 threadClassName = `chat-thread${keyboardOpen && hasKeyboardViewportDisplacement ? " chat-thread--keyboard-active" : ""}`; 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 roomContext = useMemo(() => { if (!chatRoomsEnabled || chatScope !== "rooms" || !rooms.activeRoom) { return null; } return { roomId: rooms.activeRoom.id, roomName: rooms.activeRoom.name, memberIds: new Set(rooms.activeRoomMembers.map((member) => member.agentId)), }; }, [chatRoomsEnabled, chatScope, rooms.activeRoom, rooms.activeRoomMembers]); const filteredMentionAgents = useMemo(() => { const matchingAgents = mentionAgents.filter((agent) => matchesAgentMentionFilter(agent.name, mentionFilter)); if (!roomContext) { return matchingAgents; } const memberAgents = matchingAgents.filter((agent) => roomContext.memberIds.has(agent.id)); if (mentionFilter.trim().length === 0) { return memberAgents; } const otherAgents = matchingAgents.filter((agent) => !roomContext.memberIds.has(agent.id)); return [...memberAgents, ...otherAgents]; }, [mentionAgents, mentionFilter, roomContext]); 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 anchorToBottom = useCallback((container: HTMLElement) => { if (!container.isConnected) return; let frame = 0; let stableFrames = 0; let lastScrollHeight = -1; const maxFrames = 6; const writeBottom = () => { if (!container.isConnected) return; container.scrollTop = container.scrollHeight; if (container.scrollHeight === lastScrollHeight) { stableFrames += 1; } else { stableFrames = 0; lastScrollHeight = container.scrollHeight; } frame += 1; if (frame >= maxFrames || stableFrames >= 2) { setIsUserScrolling(false); isUserScrollingRef.current = false; return; } window.requestAnimationFrame(writeBottom); }; writeBottom(); }, []); const scrollToBottom = useCallback(() => { const messagesContainer = messagesContainerRef.current; if (!messagesContainer) return; anchorToBottom(messagesContainer); }, [anchorToBottom]); useLayoutEffect(() => { if (directThreadDeferredAnchorTimeoutRef.current !== null) { window.clearTimeout(directThreadDeferredAnchorTimeoutRef.current); directThreadDeferredAnchorTimeoutRef.current = null; } const threadId = roomThreadActive ? (rooms.activeRoom?.id ?? null) : (activeSession?.id ?? null); if (!threadId) { lastAnchoredThreadStateRef.current = null; return; } const nextState = { threadId, loaded: roomThreadActive ? !rooms.messagesLoading : !messagesLoading, hasMessages: roomThreadActive ? rooms.messages.length > 0 : messages.length > 0, }; const previousState = lastAnchoredThreadStateRef.current; const isThreadChanged = previousState?.threadId !== threadId; const finishedLoading = previousState?.threadId === threadId && !previousState.loaded && nextState.loaded; const firstMessagesArrived = previousState?.threadId === threadId && !previousState.hasMessages && nextState.hasMessages; const shouldAnchor = previousState === null || isThreadChanged || finishedLoading || firstMessagesArrived; if (!shouldAnchor) { return; } const messagesContainer = messagesContainerRef.current; if (!messagesContainer) { return; } anchorToBottom(messagesContainer); if (!roomThreadActive) { directThreadDeferredAnchorTimeoutRef.current = window.setTimeout(() => { directThreadDeferredAnchorTimeoutRef.current = null; if (isUserScrollingRef.current) { return; } const latestContainer = messagesContainerRef.current; if (!latestContainer) { return; } anchorToBottom(latestContainer); }, 250); } lastAnchoredThreadStateRef.current = nextState; return () => { if (directThreadDeferredAnchorTimeoutRef.current !== null) { window.clearTimeout(directThreadDeferredAnchorTimeoutRef.current); directThreadDeferredAnchorTimeoutRef.current = null; } }; }, [ roomThreadActive, rooms.activeRoom?.id, rooms.messages.length, rooms.messagesLoading, activeSession?.id, messages.length, messagesLoading, anchorToBottom, ]); const activeThreadMessages = roomThreadActive ? rooms.messages : messages; // 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(); } }, [activeThreadMessages, 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]); useEffect(() => { const previousScope = previousChatScopeRef.current; previousChatScopeRef.current = chatScope; if (chatScope !== "direct") { return; } if (previousScope !== null && previousScope !== "rooms") { return; } const messagesContainer = messagesContainerRef.current; if (!messagesContainer) { return; } anchorToBottom(messagesContainer); isUserScrollingRef.current = false; setIsUserScrolling(false); }, [chatScope, anchorToBottom]); useEffect(() => { if (!activeSession && !roomThreadActive) { return; } if (roomThreadActive && !isMobile) { return; } const reAnchorToLatest = () => { const messagesContainer = messagesContainerRef.current; if (!messagesContainer) { return; } anchorToBottom(messagesContainer); isUserScrollingRef.current = false; setIsUserScrolling(false); if (visibilityReanchorTimeoutRef.current !== null) { window.clearTimeout(visibilityReanchorTimeoutRef.current); visibilityReanchorTimeoutRef.current = null; } visibilityReanchorTimeoutRef.current = window.setTimeout(() => { visibilityReanchorTimeoutRef.current = null; if (isUserScrollingRef.current) { return; } const connectedContainer = messagesContainerRef.current; if (!connectedContainer) { return; } anchorToBottom(connectedContainer); }, 250); }; const onVisibilityChange = () => { if (document.visibilityState !== "visible") { return; } reAnchorToLatest(); }; document.addEventListener("visibilitychange", onVisibilityChange); window.addEventListener("pageshow", reAnchorToLatest); return () => { document.removeEventListener("visibilitychange", onVisibilityChange); window.removeEventListener("pageshow", reAnchorToLatest); if (visibilityReanchorTimeoutRef.current !== null) { window.clearTimeout(visibilityReanchorTimeoutRef.current); visibilityReanchorTimeoutRef.current = null; } }; }, [isMobile, activeSession, roomThreadActive, anchorToBottom]); useEffect(() => { if (roomThreadActive) { return; } if (typeof ResizeObserver === "undefined") { return; } const messagesContainer = messagesContainerRef.current; if (!messagesContainer) { return; } const observer = new ResizeObserver(() => { if (isUserScrollingRef.current) { return; } anchorToBottom(messagesContainer); }); observer.observe(messagesContainer); return () => { observer.disconnect(); }; }, [roomThreadActive, anchorToBottom, activeSession?.id, chatScope]); // 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 resizeComposer = useCallback((textarea?: HTMLTextAreaElement | null) => { const composer = textarea ?? inputRef.current; if (!composer) { return; } composer.style.height = "auto"; composer.style.height = `${clampChatInputHeight(composer.scrollHeight)}px`; }, []); const handleComposerRef = useCallback((textarea: HTMLTextAreaElement | null) => { inputRef.current = textarea; if (!textarea) { return; } resizeComposer(textarea); }, [resizeComposer]); useLayoutEffect(() => { resizeComposer(); }, [chatScope, messageInput, activeSession?.id, rooms.activeRoom?.id, resizeComposer]); const clearComposerState = useCallback(() => { setMessageInput(""); if (activeDraftKey) { try { localStorage.removeItem(activeDraftKey); } catch { // Ignore storage errors. } } setShowSkillMenu(false); setSkillFilter(""); setMentionPopupVisible(false); setMentionFilter(""); setMentionStartPos(-1); setPendingAttachments((prev) => { for (const attachment of prev) { if (attachment.previewUrl) { URL.revokeObjectURL(attachment.previewUrl); } } return []; }); }, [activeDraftKey]); // 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" || trimmed === "/new") { 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 handleSendDispatch = useCallback(async () => { const trimmed = messageInput.trim(); if (!trimmed) { return; } if (chatRoomsEnabled && chatScope === "rooms") { if (!rooms.activeRoom) { return; } if (trimmed === "/clear" || trimmed === "/new") { clearComposerState(); try { await rooms.clearRoom(rooms.activeRoom.id); } catch { addToast("Failed to clear room conversation", "error"); } return; } const previousInput = messageInput; clearComposerState(); try { await rooms.sendRoomMessage(trimmed, { files: pendingAttachments.map((attachment) => attachment.file) }); } catch (error) { setMessageInput(previousInput); const message = error instanceof Error && error.message.trim() ? error.message : "Failed to send room message"; addToast(message, "error"); } return; } handleSend(); }, [messageInput, pendingAttachments, chatRoomsEnabled, chatScope, rooms, rooms.clearRoom, clearComposerState, addToast, handleSend]); 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; resizeComposer(inputRef.current); inputRef.current.focus(); }); return nextInput; }); setShowSkillMenu(false); setSkillFilter(""); setHighlightedSkillIndex(0); }, [resizeComposer], ); 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; resizeComposer(inputRef.current); inputRef.current.focus(); inputRef.current.setSelectionRange(nextCursorPos, nextCursorPos); }); }, [mentionStartPos, messageInput, resizeComposer], ); // 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 handleSendDispatch(); } }, [ mentionPopupVisible, filteredMentionAgents, mentionHighlightIndex, handleMentionSelect, showSkillMenu, filteredSkills, highlightedSkillIndex, handleSkillSelect, handleSendDispatch, 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); } resizeComposer(textarea); }, [updateMentionState, resizeComposer]); 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) => { const selectedSession = filteredSessions.find((session) => session.id === id); markRead("direct", id, selectedSession?.lastMessageAt ?? selectedSession?.updatedAt); selectSession(id); setMobileSessionMenuOpen(false); if (isMobile) setSidebarVisible(false); }, [filteredSessions, isMobile, markRead, selectSession], ); // Handle back to sidebar (mobile) const handleBack = useCallback(() => { selectSession(""); setSidebarVisible(true); setMobileSessionMenuOpen(false); }, [selectSession]); const handleRoomBack = useCallback(() => { rooms.selectRoom(null); setSidebarVisible(true); setMobileSessionMenuOpen(false); }, [rooms]); // 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 hasMobileDetailSelection = chatScope === "rooms" ? roomThreadActive : Boolean(activeSession); const previousHasMobileDetailSelectionRef = useRef(hasMobileDetailSelection); useEffect(() => { const previousHasMobileDetailSelection = previousHasMobileDetailSelectionRef.current; previousHasMobileDetailSelectionRef.current = hasMobileDetailSelection; if (!isMobile) { return; } if (previousHasMobileDetailSelection || !hasMobileDetailSelection) { return; } // Mobile list/detail surfaces must stack a view entry on top of the // shared browser-history nav entry so swipe-back returns to the list. pushNav({ type: "view", revert: chatScope === "rooms" ? handleRoomBack : handleBack, }); }, [chatScope, handleBack, handleRoomBack, hasMobileDetailSelection, isMobile, pushNav]); 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 showMobileSessionSwitcher = isMobile && chatScope === "direct" && !!activeSession; 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); }, []); useEffect(() => { if (!mobileSessionMenuOpen) { return; } const handlePointerDown = (event: MouseEvent) => { if (mobileSessionMenuRef.current?.contains(event.target as Node)) { return; } setMobileSessionMenuOpen(false); }; document.addEventListener("mousedown", handlePointerDown); return () => { document.removeEventListener("mousedown", handlePointerDown); }; }, [mobileSessionMenuOpen]); useEffect(() => { if (!roomSwitcherOpen) { return; } const handlePointerDown = (event: MouseEvent) => { if (roomSwitcherRef.current?.contains(event.target as Node)) { return; } setRoomSwitcherOpen(false); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setRoomSwitcherOpen(false); } }; document.addEventListener("mousedown", handlePointerDown); document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("mousedown", handlePointerDown); document.removeEventListener("keydown", handleKeyDown); }; }, [roomSwitcherOpen]); useEffect(() => { if (!isMobile || chatScope !== "direct" || sidebarVisible) { setMobileSessionMenuOpen(false); } }, [isMobile, chatScope, sidebarVisible]); useEffect(() => { setRoomSwitcherOpen(false); }, [rooms.activeRoom?.id]); 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 */}
    {chatRoomsEnabled && (
    )} {!chatRoomsEnabled || chatScope === "direct" ? ( <> {/* 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) => { const isActive = activeSession?.id === session.id; const showUnreadDot = !isActive && isUnread("direct", session.id, session.lastMessageAt ?? session.updatedAt); return (
    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"} {showUnreadDot ? ( ) : null}
    {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) : ""}
    ); }) )}
    ) : (
    {!isMobile && (
    )} {rooms.rooms.length === 0 ? (
    No rooms yet.
    ) : (
    {rooms.rooms.map((room) => { const isActive = rooms.activeRoom?.id === room.id; const showUnreadDot = !isActive && isUnread("room", room.id, room.updatedAt); return (
    { markRead("room", room.id, room.updatedAt); rooms.selectRoom(room.id); if (isMobile) { setSidebarVisible(false); } }} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); markRead("room", room.id, room.updatedAt); rooms.selectRoom(room.id); if (isMobile) { setSidebarVisible(false); } } }} > #{room.name} {showUnreadDot ? ( ) : null} {isActive ? ( {rooms.activeRoomMembers.length} {rooms.activeRoomMembers.length === 1 ? "member" : "members"} ) : null}
    ); })}
    )}
    )} {chatScope === "rooms" ? ( isMobile ? (
    ) : null ) : (
    )}
    {!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.

    )} {chatRoomsEnabled && confirmDeleteRoomId && (
    setConfirmDeleteRoomId(null)}>
    e.stopPropagation()}>

    Delete Room?

    This action cannot be undone. This room and all its messages will be permanently deleted.

    )} {/* Thread */} {chatRoomsEnabled && chatScope === "rooms" ? (
    {rooms.activeRoom ? ( <>
    {isMobile && ( )}
    {roomSwitcherOpen && (
    {rooms.rooms.map((room) => ( ))}
    )}
    {rooms.activeRoomMembers.map((member) => ( ))}
    {rooms.messagesLoading ? (
    Loading messages...
    ) : rooms.messages.length === 0 ? (
    No messages yet. Start the conversation!
    ) : ( rooms.messages.map((message) => { const senderName = message.senderAgentId ? (agentsMap.get(message.senderAgentId)?.name ?? message.senderAgentId.slice(0, 30)) : "You"; const roomMessage: ChatMessageInfo = { id: message.id, sessionId: message.roomId, role: message.role, content: message.content, thinkingOutput: message.thinkingOutput ?? undefined, toolCalls: undefined, fallbackInfo: undefined, attachments: message.attachments, createdAt: message.createdAt, }; return ( ); }) )}
    {rooms.activeRoom && isUserScrolling && ( )} ) : (
    Select a room or create one
    )} {rooms.activeRoom && (