import "./QuickChatFAB.css";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
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, Paperclip, Send, Square, 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";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { ChatToolCalls } from "./ChatToolCalls";
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") => 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 renderToolCalls(toolCalls?: ToolCallInfo[], compact = false): ReactNode {
return ;
}
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;
}
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);
// 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,
};
}
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,
};
}
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 { keyboardOverlap, viewportHeight } = useMobileKeyboard({ enabled: isOpen });
const keyboardPanelStyle: CSSProperties =
keyboardOverlap > 0
? ({
"--keyboard-overlap": `${keyboardOverlap}px`,
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
} as CSSProperties)
: {};
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());
/** Pending attachments staged in the composer before being sent. */
const [pendingAttachments, setPendingAttachments] = useState([]);
const [isAttachmentDragOver, setIsAttachmentDragOver] = 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 dragDepthRef = useRef(0);
// Draggable hook for FAB positioning
const {
position,
isDragging,
handlePointerDown,
handlePointerMove,
handlePointerUp,
} = 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,
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 fileInputRef = useRef(null);
const pendingAttachmentsRef = useRef([]);
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);
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) {
setSelectedModel(defaultSelection);
setChatMode("model");
return;
}
}
// Fallback: auto-select first model only when no agents exist.
if (agents.length === 0) {
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;
}
const shouldRetrySessionInit = sessionTargetKey === prevSessionTargetRef.current
&& !activeSession
&& !sessionsLoading;
if (sessionTargetKey === prevSessionTargetRef.current && !shouldRetrySessionInit) {
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,
activeSession,
sessionsLoading,
startModelChat,
switchSession,
]);
useEffect(() => {
if (isOpen) {
return;
}
setMentionPopupVisible(false);
setMentionFilter("");
setMentionStartPos(-1);
pendingAttachmentsRef.current.forEach((attachment) => {
if (attachment.previewUrl) {
URL.revokeObjectURL(attachment.previewUrl);
}
});
setPendingAttachments([]);
}, [isOpen]);
useEffect(() => {
pendingAttachmentsRef.current = pendingAttachments;
}, [pendingAttachments]);
// Attachment object URLs must be revoked when the composer unmounts.
useEffect(() => {
return () => {
pendingAttachmentsRef.current.forEach((attachment) => {
if (attachment.previewUrl) {
URL.revokeObjectURL(attachment.previewUrl);
}
});
};
}, []);
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;
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 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);
try {
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.
}
}, [sendMessage, inputDisabled, messageInput]);
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 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]);
return (
<>
{showFAB && (
)}
{isOpen && (