import "./QuickChatFAB.css"; import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { Components } from "react-markdown"; import { Eye, EyeOff, MessageSquare, Send, Square, Wrench, X } from "lucide-react"; import { fetchModels, type Agent, type ModelInfo } from "../api"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { AgentMentionPopup } from "./AgentMentionPopup"; import { FN_AGENT_ID, useQuickChat, type ChatMessageInfo, type ToolCallInfo } from "../hooks/useQuickChat"; import { useAgents } from "../hooks/useAgents"; import { FileMentionPopup } from "./FileMentionPopup"; import { useFileMention } from "../hooks/useFileMention"; interface QuickChatFABProps { projectId?: string; addToast: (msg: string, type?: "success" | "error") => void; /** When false, the FAB button is hidden but the panel can still be opened programmatically via the open prop */ showFAB?: boolean; /** When true, the chat panel is open */ open?: boolean; /** Callback when the panel should be opened/closed */ onOpenChange?: (open: boolean) => void; /** List of favorite provider names in preferred order */ favoriteProviders?: string[]; /** List of favorited model identifiers in format "{provider}/{modelId}" */ favoriteModels?: string[]; /** Called when user toggles a provider's favorite status */ onToggleFavorite?: (provider: string) => void; /** Called when user toggles a model's favorite status */ onToggleModelFavorite?: (modelId: string) => void; } interface ParsedModelSelection { modelProvider: string; modelId: string; } const modelTagStyle = { display: "inline-flex", alignItems: "center", maxWidth: "180px", padding: "var(--space-xs) var(--space-sm)", borderRadius: "var(--radius-pill)", border: "1px solid color-mix(in srgb, var(--todo) 35%, var(--border))", background: "color-mix(in srgb, var(--todo) 14%, transparent)", color: "var(--text)", fontSize: "11px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", } as const; const headerTitleWrapStyle = { display: "flex", alignItems: "center", gap: "var(--space-sm)", minWidth: 0, } as const; function getAgentLabel(agent: Agent): string { const base = agent.name?.trim() || agent.id; return `${base} (${agent.role})`; } function parseModelSelection(selectedModel: string): ParsedModelSelection | null { const value = selectedModel.trim(); const slashIndex = value.indexOf("/"); if (!value || slashIndex <= 0 || slashIndex >= value.length - 1) { return null; } return { modelProvider: value.slice(0, slashIndex), modelId: value.slice(slashIndex + 1), }; } function formatModelTagName(modelInfo: ModelInfo | null, parsedSelection: ParsedModelSelection | null): string | null { if (!parsedSelection) { return null; } if (modelInfo?.name?.trim()) { return modelInfo.name.trim(); } return parsedSelection.modelId .replace(/[-_]/g, " ") .replace(/\s+/g, " ") .replace(/^\w/, (letter) => letter.toUpperCase()) .trim(); } function truncateValue(value: string, maxLength: number): string { return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value; } 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]) => { let stringValue = ""; if (typeof value === "string") { stringValue = value; } else { try { stringValue = JSON.stringify(value); } catch { stringValue = String(value); } } return `${key}=${truncateValue(stringValue, 50)}`; }) .join(", "); } function formatToolResultSummary(result: unknown): string | null { if (result === undefined) { return null; } if (typeof result === "string") { return truncateValue(result, 200); } try { return truncateValue(JSON.stringify(result), 200); } catch { return truncateValue(String(result), 200); } } function renderToolCalls(toolCalls?: ToolCallInfo[], compact = false): ReactNode { if (!toolCalls || toolCalls.length === 0) { return null; } return (
{toolCalls.map((toolCall, index) => { 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 quickChatMarkdownComponents: Components = { pre: ({ children, ...props }) => (
      {children}
    
), table: ({ children, ...props }) => ( {children}
), }; function getMentionTriggerMatch( value: string, cursorPos: number, ): { filter: string; start: number; end: number } | null { const textBeforeCursor = value.slice(0, cursorPos); const triggerMatch = /(^|[\s])@([\w-]*)$/.exec(textBeforeCursor); if (!triggerMatch) { return null; } const filter = triggerMatch[2] ?? ""; const start = textBeforeCursor.length - filter.length - 1; return { filter, start, end: cursorPos, }; } /** Position type for FAB positioning (right and bottom offsets from viewport edges) */ interface Position { x: number; y: number; } /** * Custom hook for draggable behavior. * Positions are stored as right/bottom offsets (matching the current positioning model). * Position persists in localStorage keyed per-project. * @param projectId - Optional project ID for localStorage key * @param externalDidDragRef - External ref to track drag state for click detection */ function useDraggable(projectId?: string, externalDidDragRef?: React.MutableRefObject) { // Get executor footer height from CSS variable const getFooterHeight = useCallback((): number => { if (typeof window === "undefined") return 0; const height = getComputedStyle(document.documentElement) .getPropertyValue("--executor-footer-height") .trim(); return height ? parseFloat(height) || 0 : 0; }, []); // Default positions const getDefaultPosition = useCallback((): Position => { // Mobile uses tighter default offset (4px vs 24px) to maximize screen space if (typeof window !== "undefined" && window.innerWidth <= 768) { return { x: 4, y: 4 + getFooterHeight() }; } return { x: 24, y: 24 + getFooterHeight() }; }, [getFooterHeight]); // Load position from localStorage on mount const [position, setPosition] = useState(() => { if (typeof window === "undefined") return getDefaultPosition(); const storageKey = `fusion-quick-chat-position-${projectId || "default"}`; try { const saved = localStorage.getItem(storageKey); if (saved) { const parsed = JSON.parse(saved) as Position; // Validate the parsed position has valid numbers if (typeof parsed.x === "number" && typeof parsed.y === "number" && !isNaN(parsed.x) && !isNaN(parsed.y)) { return parsed; } } } catch { // Ignore parse errors, fall back to default } return getDefaultPosition(); }); const [isDragging, setIsDragging] = useState(false); const dragStartRef = useRef<{ x: number; y: number; pointerX: number; pointerY: number } | null>(null); // Use external ref if provided, otherwise create internal one const didDragRef = externalDidDragRef ?? useRef(false); // Clamp position to keep FAB within viewport const clampPosition = useCallback((pos: Position): Position => { if (typeof window === "undefined") return pos; const fabSize = 48; // FAB is 48x48px // Mobile uses tighter margin (4px) to maximize screen space on small devices const edgeMargin = window.innerWidth <= 768 ? 4 : 8; // Account for mobile nav height when clamping bottom const mobileNavHeight = window.innerWidth <= 768 ? 44 : 0; // Account for executor footer height on desktop const footerHeight = window.innerWidth > 768 ? getFooterHeight() : 0; const maxX = window.innerWidth - fabSize - edgeMargin; const maxY = window.innerHeight - fabSize - edgeMargin - mobileNavHeight - footerHeight; return { x: Math.max(edgeMargin, Math.min(maxX, pos.x)), y: Math.max(edgeMargin, Math.min(maxY, pos.y)), }; }, [getFooterHeight]); // Persist position to localStorage const savePosition = useCallback((pos: Position) => { if (typeof window === "undefined") return; const storageKey = `fusion-quick-chat-position-${projectId || "default"}`; try { localStorage.setItem(storageKey, JSON.stringify(pos)); } catch { // Ignore storage errors } }, [projectId]); // Handle pointer down (start drag) const handlePointerDown = useCallback((e: React.PointerEvent) => { // Only handle primary button (left click) or touch if (e.button !== 0 && e.pointerType === "mouse") return; // Check if this is a click on an interactive element inside the FAB (not the FAB itself) const target = e.target as HTMLElement; const fabButton = target.closest(".quick-chat-fab") as HTMLElement | null; if (!fabButton) return; e.preventDefault(); // setPointerCapture may not exist in jsdom/tests if (typeof fabButton.setPointerCapture === "function") { fabButton.setPointerCapture(e.pointerId); } dragStartRef.current = { x: position.x, y: position.y, pointerX: e.clientX, pointerY: e.clientY, }; didDragRef.current = false; setIsDragging(true); // Prevent text selection during drag document.body.style.userSelect = "none"; }, [position]); // Handle pointer move (during drag) const handlePointerMove = useCallback((e: React.PointerEvent) => { if (!dragStartRef.current || !isDragging) return; const deltaX = e.clientX - dragStartRef.current.pointerX; const deltaY = e.clientY - dragStartRef.current.pointerY; // Check if we've moved enough to be considered a drag (>= 5px) if (Math.abs(deltaX) >= 5 || Math.abs(deltaY) >= 5) { didDragRef.current = true; } if (didDragRef.current) { // Move in the opposite direction (dragging right moves FAB right, which means reducing right offset) const newX = dragStartRef.current.x - deltaX; const newY = dragStartRef.current.y - deltaY; const clamped = clampPosition({ x: newX, y: newY }); setPosition(clamped); } }, [isDragging, clampPosition]); // Handle pointer up (end drag) const handlePointerUp = useCallback((e: React.PointerEvent) => { if (!dragStartRef.current) return; const fabButton = (e.target as HTMLElement).closest(".quick-chat-fab") as HTMLElement | null; if (fabButton && typeof fabButton.releasePointerCapture === "function") { fabButton.releasePointerCapture(e.pointerId); } setIsDragging(false); // Restore text selection document.body.style.userSelect = ""; // If we didn't drag (movement < 5px), this was a click - caller handles toggle if (!didDragRef.current) { dragStartRef.current = null; return; } // Save position to localStorage savePosition(position); dragStartRef.current = null; didDragRef.current = false; }, [position, savePosition]); return { position, isDragging, handlePointerDown, handlePointerMove, handlePointerUp, }; } export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpenChange, favoriteProviders = [], favoriteModels = [], onToggleFavorite, onToggleModelFavorite, }: QuickChatFABProps) { const { agents } = useAgents(projectId); // Internal state for uncontrolled mode, controlled state when open prop is provided const [internalOpen, setInternalOpen] = useState(false); const isControlled = open !== undefined; const isOpen = isControlled ? open : internalOpen; const setIsOpen = isControlled ? (value: boolean | ((prev: boolean) => boolean)) => { if (typeof value === "function") { onOpenChange?.(value(isOpen)); } else { onOpenChange?.(value); } } : setInternalOpen; const [chatMode, setChatMode] = useState<"agent" | "model">("agent"); const [selectedAgentId, setSelectedAgentId] = useState(""); const [models, setModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [selectedModel, setSelectedModel] = useState(""); const [messageInput, setMessageInput] = useState(""); const [mentionFilter, setMentionFilter] = useState(""); const [mentionPopupVisible, setMentionPopupVisible] = useState(false); const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0); const [mentionStartPos, setMentionStartPos] = useState(-1); const [plainTextMessageIds, setPlainTextMessageIds] = useState>(() => new Set()); // 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 input const updateFileMentionPosition = useCallback((input: HTMLInputElement | null) => { if (!input || !fileMention.mentionActive) return; // Get input position const rect = input.getBoundingClientRect(); // Position above the input, 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]); // Track if we just finished a drag (to prevent click from firing after drag) const didDragRef = useRef(false); const modelsRequestedRef = useRef(false); const prevSessionTargetRef = useRef(""); const mentionCursorPosRef = useRef(0); const hideMentionPopupTimeoutRef = useRef(null); // Draggable hook for FAB positioning const { position, isDragging, handlePointerDown, handlePointerMove, handlePointerUp, } = useDraggable(projectId, didDragRef); // Chat session hook const { activeSession, messages, isStreaming, streamingText, streamingThinking, streamingToolCalls, sessionsLoading, messagesLoading, sendMessage, stopStreaming, pendingMessage, clearPendingMessage, switchSession, startModelChat, startFreshSession, } = useQuickChat(projectId, addToast); const panelRef = useRef(null); const fabRef = useRef(null); const messagesRef = useRef(null); const inputRef = useRef(null); const parsedModelSelection = useMemo(() => parseModelSelection(selectedModel), [selectedModel]); const selectedModelInfo = useMemo( () => models.find((model) => `${model.provider}/${model.id}` === selectedModel) ?? null, [models, selectedModel], ); const selectedModelTag = useMemo( () => formatModelTagName(selectedModelInfo, parsedModelSelection), [selectedModelInfo, parsedModelSelection], ); const sessionTargetKey = useMemo(() => { if (chatMode === "model") { if (parsedModelSelection) { return `${FN_AGENT_ID}::${parsedModelSelection.modelProvider}/${parsedModelSelection.modelId}`; } return ""; } // chatMode === "agent" if (selectedAgentId) { return `${selectedAgentId}::`; } return ""; }, [chatMode, parsedModelSelection, selectedAgentId]); const hasChatTarget = chatMode === "agent" ? Boolean(selectedAgentId) : Boolean(parsedModelSelection); useEffect(() => { if (agents.length === 0) { setSelectedAgentId(""); setChatMode("model"); return; } const selectedStillExists = agents.some((agent) => agent.id === selectedAgentId); if (!selectedStillExists) { setSelectedAgentId(agents[0]?.id ?? ""); } }, [agents, selectedAgentId]); // Lazy-load models on first panel open. useEffect(() => { if (!isOpen || modelsRequestedRef.current) { return; } modelsRequestedRef.current = true; setModelsLoading(true); fetchModels() .then((response) => { const loadedModels = response.models ?? []; setModels(loadedModels); // Auto-select first model when no agents exist and no model selected yet if (agents.length === 0 && loadedModels.length > 0 && !selectedModel) { const firstModel = loadedModels[0]; if (firstModel) { setSelectedModel(`${firstModel.provider}/${firstModel.id}`); } } }) .catch((error: unknown) => { console.error("[QuickChatFAB] Failed to load models:", error); setModels([]); }) .finally(() => { setModelsLoading(false); }); }, [isOpen, agents.length, selectedModel]); // Initialize/switch quick chat session whenever the selected target changes. useEffect(() => { if (!isOpen) { prevSessionTargetRef.current = ""; return; } if (!sessionTargetKey) { prevSessionTargetRef.current = ""; return; } if (sessionTargetKey === prevSessionTargetRef.current) { return; } prevSessionTargetRef.current = sessionTargetKey; if (chatMode === "model" && parsedModelSelection) { void startModelChat(parsedModelSelection.modelProvider, parsedModelSelection.modelId); return; } if (chatMode === "agent" && selectedAgentId) { void switchSession(selectedAgentId); } }, [isOpen, chatMode, parsedModelSelection, selectedAgentId, sessionTargetKey, startModelChat, switchSession]); useEffect(() => { if (isOpen) { return; } setMentionPopupVisible(false); setMentionFilter(""); setMentionStartPos(-1); }, [isOpen]); const handleAgentChange = useCallback((agentId: string) => { setSelectedAgentId(agentId); }, []); const handleModelChange = useCallback((value: string) => { setSelectedModel(value); }, []); const handleStartFreshChat = useCallback(() => { if (!hasChatTarget || sessionsLoading) { return; } void startFreshSession(); }, [hasChatTarget, sessionsLoading, startFreshSession]); const selectedAgent = useMemo( () => agents.find((agent) => agent.id === selectedAgentId) ?? null, [agents, selectedAgentId], ); const filteredMentionAgents = useMemo(() => { const normalizedFilter = mentionFilter.trim().toLowerCase(); if (!normalizedFilter) { return agents; } return agents.filter((agent) => agent.name.toLowerCase().includes(normalizedFilter)); }, [agents, mentionFilter]); const mentionAgentsByName = useMemo(() => { const byName = new Map(); for (const agent of agents) { byName.set(agent.name.toLowerCase(), agent); } return byName; }, [agents]); useEffect(() => { setMentionHighlightIndex(0); }, [mentionFilter, mentionPopupVisible]); useEffect(() => { return () => { if (hideMentionPopupTimeoutRef.current !== null) { window.clearTimeout(hideMentionPopupTimeoutRef.current); hideMentionPopupTimeoutRef.current = null; } }; }, []); // Click outside and escape handling useEffect(() => { if (!isOpen) return; const handleDocumentClick = (event: MouseEvent) => { const target = event.target as Node; if (panelRef.current?.contains(target)) return; if (fabRef.current?.contains(target)) return; // Don't close if clicking inside a portaled dropdown (e.g., CustomModelDropdown) if ((target as HTMLElement).closest(".model-combobox-dropdown--portal")) return; setIsOpen(false); }; const handleEscape = (event: KeyboardEvent) => { if (event.key === "Escape") { setIsOpen(false); } }; document.addEventListener("mousedown", handleDocumentClick); document.addEventListener("keydown", handleEscape); return () => { document.removeEventListener("mousedown", handleDocumentClick); document.removeEventListener("keydown", handleEscape); }; }, [isOpen, setIsOpen]); // Auto-scroll messages useEffect(() => { if (!isOpen) return; const messagesEl = messagesRef.current; if (!messagesEl) return; messagesEl.scrollTop = messagesEl.scrollHeight; }, [messages, streamingText, streamingThinking, isOpen]); const inputPlaceholder = useMemo(() => { if (chatMode === "agent") { if (selectedAgent) { return `Message ${selectedAgent.name || selectedAgent.id}`; } return "Select an agent to start chatting"; } // model mode if (selectedModelTag) { return `Message ${selectedModelTag}`; } return "Select a model to start chatting"; }, [chatMode, selectedAgent, selectedModelTag]); const inputDisabled = !hasChatTarget || !activeSession || sessionsLoading; const pendingPreview = pendingMessage.length > 50 ? `${pendingMessage.slice(0, 50)}…` : pendingMessage; const handleSendMessage = useCallback(() => { const trimmed = messageInput.trim(); if (!trimmed || inputDisabled) return; setMessageInput(""); setMentionPopupVisible(false); setMentionFilter(""); setMentionStartPos(-1); sendMessage(trimmed); }, [sendMessage, inputDisabled, 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); }, []); const handleMentionSelect = useCallback( (agent: Agent) => { const input = inputRef.current; if (!input || mentionStartPos < 0) { return; } const selectionStart = input.selectionStart ?? mentionCursorPosRef.current; const selectionEnd = input.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.focus(); inputRef.current.setSelectionRange(nextCursorPos, nextCursorPos); }); }, [mentionStartPos, messageInput], ); const handleInputChange = useCallback( (event: React.ChangeEvent) => { const nextValue = event.target.value; const cursorPos = event.target.selectionStart ?? nextValue.length; mentionCursorPosRef.current = cursorPos; setMessageInput(nextValue); updateMentionState(nextValue, cursorPos); // Detect file mentions fileMention.detectMention(nextValue, cursorPos); setFileMentionPopupVisible(fileMention.mentionActive); if (fileMention.mentionActive) { updateFileMentionPosition(event.target); } }, [updateMentionState, fileMention, updateFileMentionPosition], ); const handleInputBlur = useCallback(() => { if (hideMentionPopupTimeoutRef.current !== null) { window.clearTimeout(hideMentionPopupTimeoutRef.current); } hideMentionPopupTimeoutRef.current = window.setTimeout(() => { setMentionPopupVisible(false); setMentionFilter(""); setMentionStartPos(-1); setFileMentionPopupVisible(false); fileMention.dismissMention(); hideMentionPopupTimeoutRef.current = null; }, 120); }, [fileMention]); const handleInputFocus = useCallback(() => { if (hideMentionPopupTimeoutRef.current !== null) { window.clearTimeout(hideMentionPopupTimeoutRef.current); hideMentionPopupTimeoutRef.current = null; } }, []); const handleInputSelectionChange = useCallback( (event: React.SyntheticEvent) => { const input = event.currentTarget; const cursorPos = input.selectionStart ?? input.value.length; mentionCursorPosRef.current = cursorPos; updateMentionState(input.value, cursorPos); // Detect file mentions fileMention.detectMention(input.value, cursorPos); setFileMentionPopupVisible(fileMention.mentionActive); if (fileMention.mentionActive) { updateFileMentionPosition(input); } }, [updateMentionState, fileMention, updateFileMentionPosition], ); const handleInputKeyUp = useCallback( (event: React.KeyboardEvent) => { if (event.key === "Escape") { return; } handleInputSelectionChange(event); }, [handleInputSelectionChange], ); const renderMessageContent = useCallback( (content: string) => { const mentionRegex = /@([\w-]+)/g; const parts: ReactNode[] = []; let lastIndex = 0; let match = mentionRegex.exec(content); while (match) { const [fullMatch, rawName = ""] = match; const start = match.index; if (start > lastIndex) { parts.push(content.slice(lastIndex, start)); } const normalizedName = rawName.replace(/_/g, " ").toLowerCase(); const mentionedAgent = mentionAgentsByName.get(normalizedName); if (mentionedAgent) { parts.push( @{mentionedAgent.name.replace(/\s+/g, "_")} , ); } else { parts.push(fullMatch); } lastIndex = start + fullMatch.length; match = mentionRegex.exec(content); } if (lastIndex < content.length) { parts.push(content.slice(lastIndex)); } if (parts.length === 0) { return content; } return parts; }, [mentionAgentsByName], ); const toggleMessageRenderMode = useCallback((messageId: string) => { setPlainTextMessageIds((current) => { const next = new Set(current); if (next.has(messageId)) { next.delete(messageId); } else { next.add(messageId); } return next; }); }, []); const renderAssistantMessageContent = useCallback( (content: string, forcePlain = false) => { if (forcePlain) { return
{content}
; } return (
{content}
); }, [], ); const handleInputKeyDown = useCallback( (event: ReactKeyboardEvent) => { mentionCursorPosRef.current = event.currentTarget.selectionStart ?? mentionCursorPosRef.current; // Handle file mention popup keyboard navigation first if (fileMention.mentionActive && fileMention.files.length > 0) { fileMention.handleKeyDown(event, messageInput); if (event.key === "Enter" || event.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 && event.key === "ArrowDown") { event.preventDefault(); if (filteredMentionAgents.length > 0) { setMentionHighlightIndex((prev) => (prev + 1) % filteredMentionAgents.length); } return; } if (mentionPopupVisible && event.key === "ArrowUp") { event.preventDefault(); if (filteredMentionAgents.length > 0) { setMentionHighlightIndex((prev) => prev === 0 ? filteredMentionAgents.length - 1 : prev - 1, ); } return; } if (mentionPopupVisible && event.key === "Enter") { event.preventDefault(); const agentToSelect = filteredMentionAgents[mentionHighlightIndex] ?? filteredMentionAgents[0]; if (agentToSelect) { handleMentionSelect(agentToSelect); } return; } if (mentionPopupVisible && event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setMentionPopupVisible(false); setMentionFilter(""); setMentionStartPos(-1); return; } if (event.key !== "Enter" || event.shiftKey) return; event.preventDefault(); void handleSendMessage(); }, [ mentionPopupVisible, filteredMentionAgents, mentionHighlightIndex, handleMentionSelect, handleSendMessage, fileMention, messageInput, ], ); // Handle FAB click - only toggle if this was a click (not a drag) // Reset didDragRef after checking to prevent double-toggle const handleFABClick = useCallback(() => { if (didDragRef.current) { // Was a drag, don't toggle didDragRef.current = false; return; } setIsOpen((prev) => !prev); }, [setIsOpen]); // Calculate panel position: 60px above the FAB (FAB is 48px tall + 12px gap) const panelY = position.y + 60; return ( <> {showFAB && ( )} {isOpen && (

Quick Chat

{selectedModelTag && ( {selectedModelTag} )}
{agents.length > 0 && (
)} {chatMode === "agent" && agents.length > 0 && (
)} {chatMode === "model" && (
)}
{sessionsLoading || messagesLoading ? (
Loading conversation…
) : messages.length === 0 && !streamingText && !streamingThinking && !isStreaming ? (
No messages yet. Start the conversation!
) : ( <> {messages.map((message: ChatMessageInfo) => { const isSent = message.role === "user"; const forcePlain = !isSent && plainTextMessageIds.has(message.id); return (
{isSent ?

{renderMessageContent(message.content)}

: ( <> {renderAssistantMessageContent(message.content, forcePlain)} )} {renderToolCalls(message.toolCalls, true)}
); })} {/* Streaming message bubble */} {isStreaming && (
{streamingText ? ( <>
{renderAssistantMessageContent(streamingText, plainTextMessageIds.has("__streaming__"))}
) : (

{streamingThinking ? "Thinking…" : "Connecting…"}

)} {renderToolCalls(streamingToolCalls, true)} {streamingThinking && (
Thinking
{streamingThinking}
)}
)} )}
{ const newText = fileMention.selectFile(file, messageInput); setMessageInput(newText); fileMention.dismissMention(); setFileMentionPopupVisible(false); inputRef.current?.focus(); }} loading={fileMention.loading} /> {pendingMessage && (
{`Queued: ${pendingPreview}`}
)}
{isStreaming ? ( ) : ( )}
)} ); }