import "./QuickChatFAB.css"; import { memo, useCallback, useEffect, useLayoutEffect, 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 { ChevronDown, Eye, EyeOff, MessageSquare, Paperclip, Plus, Send, Square, Wrench, X } from "lucide-react"; import { fetchDiscoveredSkills, fetchModels, type Agent, type ModelInfo } from "../api"; import type { DiscoveredSkill } from "@fusion/dashboard"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { ProviderIcon } from "./ProviderIcon"; import { AgentMentionPopup } from "./AgentMentionPopup"; import { matchesAgentMentionFilter } from "./mentionMatching"; 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"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useViewportMode } from "../hooks/useViewportMode"; interface PendingAttachment { file: File; /** Object URL for image previews; empty string for non-image attachments. */ previewUrl: string; } interface QuickChatFABProps { projectId?: string; addToast: (msg: string, type?: "success" | "error" | "warning") => 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; } 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 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[], compact = false): 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 baseSummaryPreview = isRunning ? argsSummary : resultSummary ? `result: ${resultSummary}` : argsSummary ? `args: ${argsSummary}` : null; const summaryPreview = compact ? null : baseSummaryPreview; const statusLabel = isRunning ? "running" : isError ? "error" : "completed"; return (
{argsSummary && (
args {argsSummary}
)} {resultSummary && (
result {resultSummary}
)}
); }; const className = `chat-tool-calls${compact ? " chat-tool-calls--compact" : ""}`; 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 quickChatMarkdownComponents: Components = { pre: ({ children, ...props }) => (
      {children}
    
), table: ({ children, ...props }) => ( {children}
), }; 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])@([\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; } interface PanelSize { width: number; height: number; } type ResizeDirection = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se"; /** Offset of the panel anchor relative to the FAB position (right/bottom deltas in px). */ interface PanelAnchorOffset { right: number; bottom: number; } const QUICK_CHAT_DEFAULT_PANEL_SIZE: PanelSize = { width: 320, height: 400, }; const ALLOWED_ATTACHMENT_TYPES = new Set([ "image/png", "image/jpeg", "image/gif", "image/webp", "text/plain", "application/json", "text/yaml", "text/x-log", "text/csv", "application/xml", "text/markdown", ]); const ALLOWED_ATTACHMENT_EXTENSIONS = [ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".txt", ".json", ".yaml", ".yml", ".log", ".csv", ".xml", ".md", ]; function isImageAttachment(file: File): boolean { return file.type.startsWith("image/"); } function isAllowedAttachment(file: File): boolean { if (ALLOWED_ATTACHMENT_TYPES.has(file.type)) { return true; } const lowerName = file.name.toLowerCase(); return ALLOWED_ATTACHMENT_EXTENSIONS.some((extension) => lowerName.endsWith(extension)); } const QUICK_CHAT_MIN_PANEL_SIZE: PanelSize = { width: 280, height: 260, }; const QUICK_CHAT_DESKTOP_BREAKPOINT = 768; const QUICK_CHAT_VIEWPORT_PADDING = 8; /** * 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); const positionRef = useRef(position); const activePointerIdRef = useRef(null); const dragTargetRef = useRef(null); // Use external ref if provided, otherwise create internal one const didDragRef = externalDidDragRef ?? useRef(false); useEffect(() => { positionRef.current = position; }, [position]); // 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]); const endDrag = useCallback(() => { if (!dragStartRef.current) { return; } const dragTarget = dragTargetRef.current; const pointerId = activePointerIdRef.current; if (dragTarget && pointerId !== null && typeof dragTarget.releasePointerCapture === "function") { dragTarget.releasePointerCapture(pointerId); } dragStartRef.current = null; activePointerIdRef.current = null; dragTargetRef.current = null; setIsDragging(false); document.body.style.userSelect = ""; if (didDragRef.current) { savePosition(positionRef.current); } document.removeEventListener("pointermove", handleDocumentPointerMove); document.removeEventListener("pointerup", handleDocumentPointerUp); document.removeEventListener("pointercancel", handleDocumentPointerCancel); }, [savePosition]); const handleDocumentPointerMove = useCallback((event: PointerEvent) => { if (!dragStartRef.current) { return; } if (activePointerIdRef.current !== null && event.pointerId !== activePointerIdRef.current) { return; } const deltaX = event.clientX - dragStartRef.current.pointerX; const deltaY = event.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) { return; } // 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 }); positionRef.current = clamped; setPosition(clamped); }, [clampPosition]); const handleDocumentPointerUp = useCallback((event: PointerEvent) => { if (activePointerIdRef.current !== null && event.pointerId !== activePointerIdRef.current) { return; } endDrag(); }, [endDrag]); const handleDocumentPointerCancel = useCallback((event: PointerEvent) => { if (activePointerIdRef.current !== null && event.pointerId !== activePointerIdRef.current) { return; } endDrag(); }, [endDrag]); // Handle pointer down (start drag) const handlePointerDown = useCallback((event: React.PointerEvent) => { // Only handle primary button (left click) or touch if (event.button !== 0 && event.pointerType === "mouse") return; const fabButton = event.currentTarget; event.preventDefault(); // setPointerCapture may not exist in jsdom/tests if (typeof fabButton.setPointerCapture === "function") { fabButton.setPointerCapture(event.pointerId); } const currentPosition = positionRef.current; dragStartRef.current = { x: currentPosition.x, y: currentPosition.y, pointerX: event.clientX, pointerY: event.clientY, }; activePointerIdRef.current = event.pointerId; dragTargetRef.current = fabButton; didDragRef.current = false; setIsDragging(true); // Prevent text selection during drag document.body.style.userSelect = "none"; document.addEventListener("pointermove", handleDocumentPointerMove, { passive: true }); document.addEventListener("pointerup", handleDocumentPointerUp); document.addEventListener("pointercancel", handleDocumentPointerCancel); }, [handleDocumentPointerCancel, handleDocumentPointerMove, handleDocumentPointerUp]); useEffect(() => () => { document.removeEventListener("pointermove", handleDocumentPointerMove); document.removeEventListener("pointerup", handleDocumentPointerUp); document.removeEventListener("pointercancel", handleDocumentPointerCancel); document.body.style.userSelect = ""; }, [handleDocumentPointerCancel, handleDocumentPointerMove, handleDocumentPointerUp]); return { position, isDragging, handlePointerDown, }; } function usePanelResize(projectId: string | undefined, fabRight: number, fabBottom: number) { const storageKey = `fusion:quick-chat-size-${projectId || "default"}`; const isDesktopViewport = useCallback( () => typeof window !== "undefined" && window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT, [], ); /** Clamp width/height given the effective anchor point (right/bottom offsets from viewport edges). */ const clampPanelSize = useCallback( (size: PanelSize, anchorRight: number, anchorBottom: number): PanelSize => { if (typeof window === "undefined") { return size; } const maxWidth = Math.max( QUICK_CHAT_MIN_PANEL_SIZE.width, window.innerWidth - anchorRight - QUICK_CHAT_VIEWPORT_PADDING, ); const maxHeight = Math.max( QUICK_CHAT_MIN_PANEL_SIZE.height, window.innerHeight - anchorBottom - QUICK_CHAT_VIEWPORT_PADDING, ); return { width: Math.max(QUICK_CHAT_MIN_PANEL_SIZE.width, Math.min(maxWidth, size.width)), height: Math.max(QUICK_CHAT_MIN_PANEL_SIZE.height, Math.min(maxHeight, size.height)), }; }, [], ); const loadPersistedSize = useCallback((): PanelSize => { if (typeof window === "undefined" || window.innerWidth <= QUICK_CHAT_DESKTOP_BREAKPOINT) { return QUICK_CHAT_DEFAULT_PANEL_SIZE; } try { const raw = localStorage.getItem(storageKey); if (!raw) return QUICK_CHAT_DEFAULT_PANEL_SIZE; const parsed = JSON.parse(raw) as Partial; if (typeof parsed.width !== "number" || typeof parsed.height !== "number") { return QUICK_CHAT_DEFAULT_PANEL_SIZE; } return { width: parsed.width, height: parsed.height }; } catch { return QUICK_CHAT_DEFAULT_PANEL_SIZE; } }, [storageKey]); const [panelSize, setPanelSize] = useState(loadPersistedSize); /** * Anchor offset relative to the FAB position. * When the user drags the south or east handle, we shift the anchor so the * panel top/left edge moves while the opposite edge stays fixed. */ const [anchorOffset, setAnchorOffset] = useState({ right: 0, bottom: 0 }); useEffect(() => { if (!isDesktopViewport()) return; const effective = { right: fabRight + anchorOffset.right, bottom: fabBottom + anchorOffset.bottom }; setPanelSize((current) => clampPanelSize(current, effective.right, effective.bottom)); }, [clampPanelSize, isDesktopViewport, fabRight, fabBottom, anchorOffset]); useEffect(() => { if (!isDesktopViewport()) return; try { localStorage.setItem(storageKey, JSON.stringify(panelSize)); } catch { // Ignore storage errors (private mode / quota) } }, [isDesktopViewport, panelSize, storageKey]); const handleResizeStart = useCallback( (event: React.PointerEvent) => { if (!isDesktopViewport()) return; const direction = event.currentTarget.dataset.resizeDirection as ResizeDirection | undefined; if (!direction) return; event.preventDefault(); event.stopPropagation(); const resizeHandle = event.currentTarget; if (typeof resizeHandle.setPointerCapture === "function") { resizeHandle.setPointerCapture(event.pointerId); } const startState = { pointerX: event.clientX, pointerY: event.clientY, width: panelSize.width, height: panelSize.height, anchorRight: anchorOffset.right, anchorBottom: anchorOffset.bottom, }; document.body.style.userSelect = "none"; const onPointerMove = (moveEvent: PointerEvent) => { const dx = moveEvent.clientX - startState.pointerX; const dy = moveEvent.clientY - startState.pointerY; let nextWidth = startState.width; let nextHeight = startState.height; let nextAnchorRight = startState.anchorRight; let nextAnchorBottom = startState.anchorBottom; // West handle: dragging left grows width (panel expands left). if (direction.includes("w")) { nextWidth = startState.width - dx; } // East handle: dragging right grows width (panel expands right). // The right anchor must shift leftward (decrease) to keep left edge fixed. if (direction.includes("e")) { const widthDelta = dx; nextWidth = startState.width + widthDelta; nextAnchorRight = startState.anchorRight - widthDelta; } // North handle: dragging up grows height (panel expands upward). if (direction.includes("n")) { nextHeight = startState.height - dy; } // South handle: dragging down grows height (panel expands downward). // The bottom anchor must shift upward (decrease) to keep the top edge fixed. if (direction.includes("s")) { const heightDelta = dy; nextHeight = startState.height + heightDelta; nextAnchorBottom = startState.anchorBottom - heightDelta; } // Clamp size against effective anchor position. const effectiveRight = fabRight + nextAnchorRight; const effectiveBottom = fabBottom + nextAnchorBottom; const clamped = clampPanelSize({ width: nextWidth, height: nextHeight }, effectiveRight, effectiveBottom); // Also clamp the anchor offsets so the panel doesn't go off-screen. const clampedAnchorRight = Math.max( QUICK_CHAT_VIEWPORT_PADDING - fabRight, Math.min( window.innerWidth - fabRight - QUICK_CHAT_MIN_PANEL_SIZE.width - QUICK_CHAT_VIEWPORT_PADDING, nextAnchorRight, ), ); const clampedAnchorBottom = Math.max( QUICK_CHAT_VIEWPORT_PADDING - fabBottom, Math.min( window.innerHeight - fabBottom - QUICK_CHAT_MIN_PANEL_SIZE.height - QUICK_CHAT_VIEWPORT_PADDING, nextAnchorBottom, ), ); setPanelSize(clamped); setAnchorOffset({ right: clampedAnchorRight, bottom: clampedAnchorBottom }); }; 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); // Persist final size. try { localStorage.setItem(storageKey, JSON.stringify({ width: panelSize.width, height: panelSize.height })); } catch { // Best-effort } }; document.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerup", onPointerUp); }, [ anchorOffset.bottom, anchorOffset.right, clampPanelSize, fabBottom, fabRight, isDesktopViewport, panelSize.height, panelSize.width, storageKey, ], ); return { panelSize, anchorOffset, handleResizeStart, }; } interface QuickChatMessageItemProps { message: ChatMessageInfo; forcePlain: boolean; mentionAgentsByName: Map; onToggleRender: (id: string) => void; } // Memoized so streaming state churn doesn't re-render every prior message // (each one would re-run ReactMarkdown over its full content otherwise). const QuickChatMessageItem = memo(function QuickChatMessageItem({ message, forcePlain, mentionAgentsByName, onToggleRender, }: QuickChatMessageItemProps) { const isSent = message.role === "user"; const renderedUserContent = useMemo(() => { if (!isSent) 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; }, [isSent, message.content, mentionAgentsByName]); const assistantBody = useMemo(() => { if (isSent) return null; if (forcePlain) { return
{message.content}
; } return (
{message.content}
); }, [isSent, forcePlain, message.content]); return (
{isSent ?

{renderedUserContent}

: ( <> {assistantBody} )} {renderToolCalls(message.toolCalls, true)}
); }); 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; // We still consume keyboardOpen for layout decisions outside the panel, // but the high-frequency --vv-offset-top / --vv-height tracking is set // directly on the panel DOM in a layout effect below — going through // React state introduces a per-event reconciliation lag that the human // eye reads as jank while the iOS keyboard is animating in. useMobileKeyboard({ enabled: isOpen }); const viewportMode = useViewportMode(); const [chatMode, setChatMode] = useState<"agent" | "model">("agent"); const [selectedAgentId, setSelectedAgentId] = useState(""); const [newSessionChooserOpen, setNewSessionChooserOpen] = useState(false); const [newSessionMode, setNewSessionMode] = useState<"agent" | "model">("model"); const [newSessionAgentId, setNewSessionAgentId] = useState(""); const [newSessionModel, setNewSessionModel] = useState(""); const [models, setModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [selectedModel, setSelectedModel] = useState(""); const [configuredDefaultModelSelection, setConfiguredDefaultModelSelection] = useState(""); const [messageInput, setMessageInput] = useState(""); const [discoveredSkills, setDiscoveredSkills] = useState([]); const [skillsLoading, setSkillsLoading] = useState(false); const [showSkillMenu, setShowSkillMenu] = useState(false); const [skillFilter, setSkillFilter] = useState(""); const [highlightedSkillIndex, setHighlightedSkillIndex] = useState(0); const [mentionFilter, setMentionFilter] = useState(""); const [mentionPopupVisible, setMentionPopupVisible] = useState(false); const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0); const [mentionStartPos, setMentionStartPos] = useState(-1); const [plainTextMessageIds, setPlainTextMessageIds] = useState>(() => new Set()); const [helpMessageVisible, setHelpMessageVisible] = useState(false); /** Pending attachments staged in the composer before being sent. */ const [pendingAttachments, setPendingAttachments] = useState([]); const [isAttachmentDragOver, setIsAttachmentDragOver] = useState(false); const [isUserScrolling, setIsUserScrolling] = useState(false); // File mention state and hook const [, setFileMentionPopupVisible] = useState(false); const [fileMentionPosition, setFileMentionPosition] = useState({ top: 0, left: 0 }); const fileMention = useFileMention({ projectId }); // Calculate popup position based on caret position in 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); const hideSkillMenuTimeoutRef = useRef(null); const dragDepthRef = useRef(0); // Draggable hook for FAB positioning const { position, isDragging, handlePointerDown, } = useDraggable(projectId, didDragRef); // Panel stays 60px above FAB (FAB is 48px tall + 12px gap) const panelY = position.y + 60; const { panelSize, anchorOffset, handleResizeStart } = usePanelResize(projectId, position.x, panelY); const shouldApplyDesktopPanelSize = typeof window !== "undefined" && window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT; // Chat session hook const { activeSession, messages, isStreaming, streamingText, streamingThinking, streamingToolCalls, sessions, sessionsLoading, messagesLoading, sendMessage, stopStreaming, pendingMessage, clearPendingMessage, switchSession, selectSession, startModelChat, startFreshSession, refreshSessions, skipNextSessionInitRef, } = useQuickChat(projectId, addToast); const panelRef = useRef(null); const fabRef = useRef(null); const messagesRef = useRef(null); const inputRef = useRef(null); const fileInputRef = useRef(null); const pendingAttachmentsRef = useRef([]); const shouldAutoFocusComposerRef = useRef(false); const handledMobileActionRef = useRef(false); const preserveComposerFocusRef = useRef(false); // Always-mounted offscreen input used to claim the iOS soft keyboard // synchronously inside the FAB click gesture, before the real composer // input has rendered (or while it is still `disabled` waiting for the // session). Focus is transferred to the real input once it is enabled — // iOS keeps the keyboard up across that transfer. const stealthInputRef = useRef(null); // Set true briefly while the keyboard is dismissing. While set, the // visualViewport apply() ignores incoming vv.height values so iOS's // mid-dismiss reports cannot shrink the panel back down — the panel // visually grows to full height immediately on blur and the keyboard // slides down on top of it. const suppressVvShrinkRef = useRef(false); const isUserScrollingRef = useRef(false); const previousOpenStateRef = useRef<{ isOpen: boolean; sessionId: string | null }>({ isOpen: false, sessionId: null }); // Pin the document at the top while the panel is open on mobile. // Otherwise iOS can leave window.scrollY > 0 (e.g. after the keyboard // was opened and dismissed once), and on the next open the // position:fixed panel anchors to layout top:0 which is *above* the // visible viewport — only the bottom of the panel (the input bar) // pokes into view at the top of the screen. // // We deliberately do NOT use `body { position: fixed }` to lock scroll: // that would make the body the containing block for the panel's // position:fixed and reintroduce the same translation bug. Instead we // scroll to 0 and lock overflow on and ; the panel's // viewport anchor stays correct. useEffect(() => { if (!isOpen) return; if (typeof window === "undefined" || typeof document === "undefined") return; if (window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return; const scrollY = window.scrollY; const html = document.documentElement; const body = document.body; const prev = { htmlOverflow: html.style.overflow, bodyOverflow: body.style.overflow, }; window.scrollTo(0, 0); html.style.overflow = "hidden"; body.style.overflow = "hidden"; return () => { html.style.overflow = prev.htmlOverflow; body.style.overflow = prev.bodyOverflow; window.scrollTo(0, scrollY); }; }, [isOpen]); // Mirror visualViewport metrics onto the panel as CSS variables // directly, bypassing React state. --vv-height shrinks the panel to // the visible area; --vv-offset-top compensates for iOS shifting the // visual viewport on input focus (without it the position:fixed panel // slides off-screen on the second focus after the keyboard has been // dismissed once). // // We deliberately do NOT throttle via requestAnimationFrame here. // iOS fires visualViewport resize/scroll events on the same frame as // its own keyboard animation; deferring our write to the next frame // makes the panel lag iOS by one paint, which is visible as a slide. // Synchronous writes keep the panel locked to the visual viewport. useLayoutEffect(() => { if (!isOpen) return; if (typeof window === "undefined" || !window.visualViewport) return; const panel = panelRef.current; if (!panel) return; const vv = window.visualViewport; const apply = () => { if (suppressVvShrinkRef.current) return; panel.style.setProperty("--vv-height", `${vv.height}px`); panel.style.setProperty("--vv-offset-top", `${vv.offsetTop || 0}px`); }; apply(); vv.addEventListener("resize", apply); vv.addEventListener("scroll", apply); return () => { vv.removeEventListener("resize", apply); vv.removeEventListener("scroll", apply); }; }, [isOpen]); const resolvedModelSelection = selectedModel || configuredDefaultModelSelection; const targetModelSelection = useMemo( () => parseModelSelection(resolvedModelSelection), [resolvedModelSelection], ); const displayedModelSelection = useMemo(() => { if (chatMode === "model" && activeSession?.modelProvider && activeSession?.modelId) { return `${activeSession.modelProvider}/${activeSession.modelId}`; } return resolvedModelSelection; }, [activeSession?.modelId, activeSession?.modelProvider, chatMode, resolvedModelSelection]); const parsedModelSelection = useMemo(() => parseModelSelection(displayedModelSelection), [displayedModelSelection]); const selectedModelInfo = useMemo( () => models.find((model) => `${model.provider}/${model.id}` === displayedModelSelection) ?? null, [displayedModelSelection, models], ); const selectedModelTag = useMemo( () => formatModelTagName(selectedModelInfo, parsedModelSelection), [selectedModelInfo, parsedModelSelection], ); const sessionTargetKey = useMemo(() => { if (chatMode === "model") { if (targetModelSelection) { return `${FN_AGENT_ID}::${targetModelSelection.modelProvider}/${targetModelSelection.modelId}`; } return ""; } // chatMode === "agent" if (selectedAgentId) { return `${selectedAgentId}::`; } return ""; }, [chatMode, selectedAgentId, targetModelSelection]); const hasChatTarget = chatMode === "agent" ? Boolean(selectedAgentId) : Boolean(targetModelSelection); const inputDisabled = !hasChatTarget || !activeSession; 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); if (selectedModel || loadedModels.length === 0) { return; } const defaultProvider = response.defaultProvider; const defaultModelId = response.defaultModelId; if (defaultProvider && defaultModelId) { const defaultSelection = `${defaultProvider}/${defaultModelId}`; const hasDefaultModel = loadedModels.some( (model) => `${model.provider}/${model.id}` === defaultSelection, ); if (hasDefaultModel) { setConfiguredDefaultModelSelection(defaultSelection); setSelectedModel(defaultSelection); // Switch to model mode regardless of whether agents are present — // a configured default model is an explicit user preference and // should drive the panel to its corresponding mode immediately, // otherwise the tag/dropdown auto-selection would be invisible // until the user manually toggles modes. setChatMode("model"); return; } } setConfiguredDefaultModelSelection(""); // Always pre-select the first model so users can start chatting in model mode // without having to manually pick from the dropdown. const firstModel = loadedModels[0]; if (firstModel) { setSelectedModel(`${firstModel.provider}/${firstModel.id}`); } }) .catch((error: unknown) => { console.error("[QuickChatFAB] Failed to load models:", error); setModels([]); setConfiguredDefaultModelSelection(""); }) .finally(() => { setModelsLoading(false); }); }, [isOpen, agents.length, selectedModel]); useEffect(() => { if (!isOpen || !projectId) { return; } setSkillsLoading(true); fetchDiscoveredSkills(projectId) .then((skills) => { setDiscoveredSkills(skills); }) .catch(() => { setDiscoveredSkills([]); }) .finally(() => { setSkillsLoading(false); }); }, [isOpen, projectId]); useEffect(() => { if (!isOpen) return; void refreshSessions(); }, [isOpen, refreshSessions]); // Initialize/switch quick chat session whenever the selected target changes. // NOTE: activeSession and sessionsLoading are in the dependency array to // enable retry-when-null (see shouldRetrySessionInit), but the hook's // switchSession now reads activeSession from a ref so it doesn't get a // new identity on every activeSession change. useEffect(() => { if (!isOpen) { prevSessionTargetRef.current = ""; return; } if (!sessionTargetKey) { prevSessionTargetRef.current = ""; return; } // When startFreshSession is in progress, skip the automatic init to // prevent racing with the explicit fresh-session creation. Record the // target key as "seen" so a later render won't re-trigger for the same // target. if (skipNextSessionInitRef.current) { prevSessionTargetRef.current = sessionTargetKey; return; } const shouldRetrySessionInit = sessionTargetKey === prevSessionTargetRef.current && !activeSession && !sessionsLoading; if (sessionTargetKey === prevSessionTargetRef.current && !shouldRetrySessionInit) { return; } prevSessionTargetRef.current = sessionTargetKey; if (chatMode === "model" && targetModelSelection) { void startModelChat(targetModelSelection.modelProvider, targetModelSelection.modelId); return; } if (chatMode === "agent" && selectedAgentId) { void switchSession(selectedAgentId); } }, [ isOpen, chatMode, targetModelSelection, selectedAgentId, sessionTargetKey, activeSession, sessionsLoading, startModelChat, switchSession, skipNextSessionInitRef, ]); useEffect(() => { if (isOpen) { return; } setMentionPopupVisible(false); setMentionFilter(""); setMentionStartPos(-1); setShowSkillMenu(false); setSkillFilter(""); setHighlightedSkillIndex(0); pendingAttachmentsRef.current.forEach((attachment) => { if (attachment.previewUrl) { URL.revokeObjectURL(attachment.previewUrl); } }); setPendingAttachments([]); }, [isOpen]); useEffect(() => { pendingAttachmentsRef.current = pendingAttachments; }, [pendingAttachments]); useEffect(() => { if (!isOpen) { shouldAutoFocusComposerRef.current = false; return; } if (typeof window === "undefined") { return; } shouldAutoFocusComposerRef.current = window.innerWidth <= QUICK_CHAT_DESKTOP_BREAKPOINT; }, [isOpen]); useEffect(() => { if (!isOpen || inputDisabled || !shouldAutoFocusComposerRef.current) { return; } const input = inputRef.current; if (!input) { return; } const activeElement = document.activeElement; const panelContainsFocus = activeElement ? panelRef.current?.contains(activeElement) : false; const isBodyFocused = activeElement === document.body; const stealthIsFocused = activeElement === stealthInputRef.current; if (!panelContainsFocus && !isBodyFocused && !stealthIsFocused) { shouldAutoFocusComposerRef.current = false; return; } // When the stealth input is currently holding the iOS keyboard, transfer // focus synchronously — going through requestAnimationFrame breaks the // keyboard handoff on Safari and the keyboard dismisses. if (stealthIsFocused) { input.focus({ preventScroll: true }); shouldAutoFocusComposerRef.current = false; return; } const frame = requestAnimationFrame(() => { input.focus(); shouldAutoFocusComposerRef.current = false; }); return () => cancelAnimationFrame(frame); }, [isOpen, inputDisabled]); // Attachment object URLs must be revoked when the composer unmounts. useEffect(() => { return () => { pendingAttachmentsRef.current.forEach((attachment) => { if (attachment.previewUrl) { URL.revokeObjectURL(attachment.previewUrl); } }); }; }, []); const handleStartFreshChat = useCallback(() => { setNewSessionChooserOpen(true); setNewSessionMode("model"); setNewSessionAgentId(agents[0]?.id ?? ""); setNewSessionModel(selectedModel || configuredDefaultModelSelection || ""); }, [agents, configuredDefaultModelSelection, selectedModel]); const selectedAgent = useMemo( () => agents.find((agent) => agent.id === selectedAgentId) ?? null, [agents, selectedAgentId], ); 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 filteredMentionAgents = useMemo( () => agents.filter((agent) => matchesAgentMentionFilter(agent.name, mentionFilter)), [agents, mentionFilter], ); const mentionAgentsByName = useMemo(() => { const byName = new Map(); for (const agent of agents) { byName.set(agent.name.toLowerCase(), agent); } return byName; }, [agents]); useEffect(() => { setHighlightedSkillIndex(0); }, [filteredSkills]); useEffect(() => { setMentionHighlightIndex(0); }, [mentionFilter, mentionPopupVisible]); useEffect(() => { return () => { if (hideMentionPopupTimeoutRef.current !== null) { window.clearTimeout(hideMentionPopupTimeoutRef.current); hideMentionPopupTimeoutRef.current = null; } if (hideSkillMenuTimeoutRef.current !== null) { window.clearTimeout(hideSkillMenuTimeoutRef.current); hideSkillMenuTimeoutRef.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]); const updateScrollState = useCallback(() => { const messagesEl = messagesRef.current; if (!messagesEl) return; const threshold = 50; const atBottom = messagesEl.scrollTop + messagesEl.clientHeight >= messagesEl.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 messagesEl = messagesRef.current; if (!messagesEl) return; anchorToBottom(messagesEl); }, [anchorToBottom]); useLayoutEffect(() => { const sessionId = activeSession?.id ?? null; const previousState = previousOpenStateRef.current; previousOpenStateRef.current = { isOpen, sessionId }; if (!isOpen || !sessionId) { return; } const openingNow = !previousState.isOpen && isOpen; const sessionChangedWhileOpen = previousState.isOpen && previousState.sessionId !== sessionId; if (!openingNow && !sessionChangedWhileOpen) { return; } const messagesEl = messagesRef.current; if (!messagesEl) return; anchorToBottom(messagesEl); }, [isOpen, activeSession?.id, anchorToBottom]); // Auto-scroll messages when user is near the live tail. useEffect(() => { if (!isOpen) return; if (!isUserScrollingRef.current) { scrollToBottom(); } }, [messages, streamingText, streamingThinking, isStreaming, isOpen, scrollToBottom]); const sessionOptions = useMemo(() => { const agentNameById = new Map(agents.map((agent) => [agent.id, agent.name?.trim() || agent.id])); const modelNameByKey = new Map( models.map((model) => [`${model.provider}/${model.id}`, model.name?.trim() || ""]), ); return sessions.map((session, index) => { const baseLabel = session.title?.trim() || `Session ${index + 1}`; let descriptor: string | null = null; if (session.agentId && session.agentId !== FN_AGENT_ID) { descriptor = agentNameById.get(session.agentId) || session.agentId; } else if (session.modelProvider && session.modelId) { const modelKey = `${session.modelProvider}/${session.modelId}`; const modelName = modelNameByKey.get(modelKey); descriptor = modelName ? `${modelName} [${modelKey}]` : modelKey; } return { id: session.id, label: descriptor ? `${baseLabel} — ${descriptor}` : baseLabel, }; }); }, [agents, models, sessions]); 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 handleSessionSwitch = useCallback((sessionId: string) => { const selectedSession = sessions.find((session) => session.id === sessionId); if (!selectedSession) { return; } if (selectedSession.modelProvider && selectedSession.modelId) { setChatMode("model"); setSelectedModel(`${selectedSession.modelProvider}/${selectedSession.modelId}`); } else { setChatMode("agent"); setSelectedAgentId(selectedSession.agentId); } void selectSession(selectedSession); }, [selectSession, sessions]); const handleCreateFreshSession = useCallback(async () => { if (sessionsLoading) return; if (newSessionMode === "agent") { if (!newSessionAgentId) return; setChatMode("agent"); setSelectedAgentId(newSessionAgentId); await startFreshSession(newSessionAgentId); } else { const parsed = parseModelSelection(newSessionModel || selectedModel || configuredDefaultModelSelection); if (!parsed) return; setChatMode("model"); setSelectedModel(`${parsed.modelProvider}/${parsed.modelId}`); await startFreshSession(FN_AGENT_ID, parsed.modelProvider, parsed.modelId); } await refreshSessions(); setNewSessionChooserOpen(false); setNewSessionMode("model"); }, [ configuredDefaultModelSelection, newSessionAgentId, newSessionMode, newSessionModel, refreshSessions, selectedModel, sessionsLoading, startFreshSession, ]); const pendingPreview = pendingMessage.length > 50 ? `${pendingMessage.slice(0, 50)}…` : pendingMessage; /** * Capture file selections from picker, paste, or drop and stage them in composer state. */ const handleAttachmentFiles = useCallback((files: FileList | null | undefined) => { if (!files || files.length === 0) { return; } const newAttachments: PendingAttachment[] = []; for (let index = 0; index < files.length; index += 1) { const file = files[index]; if (!isAllowedAttachment(file)) { continue; } newAttachments.push({ file, previewUrl: isImageAttachment(file) ? URL.createObjectURL(file) : "", }); } if (newAttachments.length > 0) { setPendingAttachments((previous) => [...previous, ...newAttachments]); } }, []); const removeAttachment = useCallback((index: number) => { setPendingAttachments((previous) => { const removed = previous[index]; if (removed?.previewUrl) { URL.revokeObjectURL(removed.previewUrl); } return previous.filter((_, attachmentIndex) => attachmentIndex !== index); }); }, []); const handlePaste = useCallback((event: React.ClipboardEvent) => { handleAttachmentFiles(event.clipboardData?.files); }, [handleAttachmentFiles]); const focusComposerInput = useCallback(() => { if (typeof window === "undefined") return; if (window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return; const input = inputRef.current; if (!input || input.disabled) return; input.focus({ preventScroll: true }); }, []); const markPreserveComposerFocus = useCallback(() => { if (typeof window === "undefined") return; if (window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return; preserveComposerFocusRef.current = true; }, []); const handleSendMessage = useCallback(async () => { const trimmed = messageInput.trim(); const attachmentsToSend = pendingAttachmentsRef.current; if (!trimmed && attachmentsToSend.length === 0) return; if (inputDisabled) return; setMessageInput(""); setMentionPopupVisible(false); setMentionFilter(""); setMentionStartPos(-1); if (trimmed === "/help") { setHelpMessageVisible(true); focusComposerInput(); preserveComposerFocusRef.current = false; return; } if (trimmed === "/clear") { stopStreaming(); clearPendingMessage(); attachmentsToSend.forEach((attachment) => { if (attachment.previewUrl) { URL.revokeObjectURL(attachment.previewUrl); } }); setPendingAttachments((previous) => previous.filter((attachment) => !attachmentsToSend.includes(attachment))); try { if (chatMode === "model") { const parsed = parseModelSelection(resolvedModelSelection); if (!parsed) { return; } await startFreshSession(FN_AGENT_ID, parsed.modelProvider, parsed.modelId); } else if (selectedAgentId) { await startFreshSession(selectedAgentId); } } catch { addToast("Failed to clear conversation", "error"); } finally { focusComposerInput(); preserveComposerFocusRef.current = false; } return; } try { setHelpMessageVisible(false); await sendMessage(trimmed, attachmentsToSend.map((attachment) => attachment.file)); attachmentsToSend.forEach((attachment) => { if (attachment.previewUrl) { URL.revokeObjectURL(attachment.previewUrl); } }); setPendingAttachments((previous) => previous.filter((attachment) => !attachmentsToSend.includes(attachment))); } catch { // Keep pending attachments on failure so user can retry. } finally { focusComposerInput(); preserveComposerFocusRef.current = false; } }, [ addToast, chatMode, clearPendingMessage, focusComposerInput, inputDisabled, messageInput, resolvedModelSelection, selectedAgentId, sendMessage, startFreshSession, stopStreaming, ]); const handleAttachmentDragEnter = useCallback((event: React.DragEvent) => { event.preventDefault(); dragDepthRef.current += 1; setIsAttachmentDragOver(true); }, []); const handleAttachmentDragOver = useCallback((event: React.DragEvent) => { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setIsAttachmentDragOver(true); }, []); const handleAttachmentDragLeave = useCallback((event: React.DragEvent) => { event.preventDefault(); dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) { setIsAttachmentDragOver(false); } }, []); const handleAttachmentDrop = useCallback((event: React.DragEvent) => { event.preventDefault(); dragDepthRef.current = 0; setIsAttachmentDragOver(false); handleAttachmentFiles(event.dataTransfer?.files); }, [handleAttachmentFiles]); 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 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.focus(); }); return nextInput; }); setShowSkillMenu(false); setSkillFilter(""); setHighlightedSkillIndex(0); }, []); 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); if (helpMessageVisible && nextValue.trim().length > 0) { setHelpMessageVisible(false); } updateMentionState(nextValue, cursorPos); const skillTriggerMatch = getSkillTriggerMatch(nextValue); if (skillTriggerMatch) { setShowSkillMenu(true); setSkillFilter(skillTriggerMatch.filter); } else { setShowSkillMenu(false); setSkillFilter(""); } // Detect file mentions fileMention.detectMention(nextValue, cursorPos); setFileMentionPopupVisible(fileMention.mentionActive); if (fileMention.mentionActive) { updateFileMentionPosition(event.target); } }, [helpMessageVisible, updateMentionState, fileMention, updateFileMentionPosition], ); const handleInputBlur = useCallback(() => { if (preserveComposerFocusRef.current) { window.requestAnimationFrame(() => { focusComposerInput(); }); return; } // Pre-grow the panel ahead of iOS's keyboard dismiss animation so the // user sees the panel snap to full height immediately instead of // following the keyboard slide-down. The suppress flag prevents the // visualViewport listener from clobbering this with mid-dismiss // reports while iOS is still animating the keyboard out. if ( typeof window !== "undefined" && window.innerWidth <= QUICK_CHAT_DESKTOP_BREAKPOINT && panelRef.current ) { suppressVvShrinkRef.current = true; panelRef.current.style.removeProperty("--vv-height"); panelRef.current.style.removeProperty("--vv-offset-top"); window.setTimeout(() => { suppressVvShrinkRef.current = false; }, 450); } 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); if (hideSkillMenuTimeoutRef.current !== null) { window.clearTimeout(hideSkillMenuTimeoutRef.current); } hideSkillMenuTimeoutRef.current = window.setTimeout(() => { setShowSkillMenu(false); hideSkillMenuTimeoutRef.current = null; }, 120); }, [fileMention, focusComposerInput]); const handleInputFocus = useCallback(() => { // Re-enable visualViewport tracking — the suppress flag set on blur // would otherwise still be in effect if the user re-focused inside // the suppress window. suppressVvShrinkRef.current = false; if (hideMentionPopupTimeoutRef.current !== null) { window.clearTimeout(hideMentionPopupTimeoutRef.current); hideMentionPopupTimeoutRef.current = null; } if (hideSkillMenuTimeoutRef.current !== null) { window.clearTimeout(hideSkillMenuTimeoutRef.current); hideSkillMenuTimeoutRef.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 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 (showSkillMenu && filteredSkills.length > 0 && event.key === "ArrowDown") { event.preventDefault(); setHighlightedSkillIndex((prev) => (prev + 1) % filteredSkills.length); return; } if (showSkillMenu && filteredSkills.length > 0 && event.key === "ArrowUp") { event.preventDefault(); setHighlightedSkillIndex((prev) => (prev === 0 ? filteredSkills.length - 1 : prev - 1)); return; } if (showSkillMenu && (event.key === "Enter" || event.key === "Tab")) { event.preventDefault(); const selectedSkill = filteredSkills[highlightedSkillIndex] ?? filteredSkills[0]; if (selectedSkill) { handleSkillSelect(selectedSkill); } return; } if (showSkillMenu && event.key === "Escape") { event.preventDefault(); setShowSkillMenu(false); setSkillFilter(""); return; } if (event.key !== "Enter" || event.shiftKey) return; event.preventDefault(); void handleSendMessage(); }, [ mentionPopupVisible, filteredMentionAgents, mentionHighlightIndex, handleMentionSelect, handleSendMessage, fileMention, messageInput, showSkillMenu, filteredSkills, highlightedSkillIndex, handleSkillSelect, ], ); // 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; } if (isOpen) { setIsOpen(false); return; } // iOS only opens the soft keyboard from a focus() that runs while // the originating user-gesture is still active, AND the focused // element must not be `disabled`. The real composer input renders // disabled until the chat session is created, so we focus an // always-mounted stealth input here to claim the keyboard now; the // auto-focus effect below transfers focus to the real input once // it is enabled, which keeps the keyboard up. if (typeof window !== "undefined" && window.innerWidth <= QUICK_CHAT_DESKTOP_BREAKPOINT) { stealthInputRef.current?.focus({ preventScroll: true }); } setIsOpen(true); }, [isOpen, setIsOpen]); return ( <> {showFAB && ( )} {isOpen && (
{shouldApplyDesktopPanelSize && ( <> {/* Edge handles */}
{/* Corner handles */}
)}

Quick Chat

{chatMode === "model" && selectedModelTag && (() => { const provider = selectedModelInfo?.provider ?? parsedModelSelection?.modelProvider ?? ""; // On mobile the header pill is squeezed by mode toggle + new-chat // + close buttons, so swap a long model name for the provider // icon to keep the title row tidy. const tagTooLong = viewportMode === "mobile" && selectedModelTag.length > 12; if (tagTooLong && provider) { return ( ); } return ( {selectedModelTag} ); })()}
{newSessionChooserOpen && (
{newSessionMode === "agent" ? (
) : (
)}
)}
{sessionsLoading ? (
Loading conversation…
) : isStreaming ? ( <> {messages.map((message: ChatMessageInfo) => ( ))} {helpMessageVisible && (
{renderAssistantMessageContent("Available commands:\n- `/clear` — Clear conversation and start fresh\n- `/skill:{name}` — Use a specific skill\n- `/help` — Show this help")}
)}
{streamingText ? ( <>
{renderAssistantMessageContent(streamingText, plainTextMessageIds.has("__streaming__"))}
) : (

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

)} {renderToolCalls(streamingToolCalls, true)} {streamingThinking && (
Thinking
{streamingThinking}
)}
) : messagesLoading ? (
Loading conversation…
) : messages.length === 0 && !streamingText && !streamingThinking && !isStreaming && !helpMessageVisible ? (
No messages yet. Start the conversation!
) : ( <> {messages.map((message: ChatMessageInfo) => ( ))} {helpMessageVisible && (
{renderAssistantMessageContent("Available commands:\n- `/clear` — Clear conversation and start fresh\n- `/skill:{name}` — Use a specific skill\n- `/help` — Show this help")}
)} )}
{isUserScrolling && ( )} {pendingAttachments.length > 0 && (
{pendingAttachments.map((attachment, index) => (
{attachment.previewUrl ? {attachment.file.name} : {attachment.file.name}}
))}
)}
{ handleAttachmentFiles(event.target.files); event.target.value = ""; }} />
{ if (typeof window === "undefined") return; if (window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return; if (document.activeElement === event.currentTarget) return; event.preventDefault(); event.currentTarget.focus({ preventScroll: true }); }} placeholder={inputPlaceholder} disabled={inputDisabled} data-testid="quick-chat-input" /> {isStreaming ? ( ) : ( )}
{ const newText = fileMention.selectFile(file, messageInput); setMessageInput(newText); fileMention.dismissMention(); setFileMentionPopupVisible(false); inputRef.current?.focus(); }} loading={fileMention.loading} /> {showSkillMenu && (
{skillsLoading ? (
Loading skills…
) : filteredSkills.length === 0 ? (
{skillFilter ? "No skills found" : "No skills available"}
) : ( filteredSkills.map((skill, index) => ( )) )}
)} {pendingMessage && (
{`Queued: ${pendingPreview}`}
)}
)} ); }