ChatView and QuickChatFAB both had an iOS-specific onTouchStart on the textarea that called event.preventDefault() and then programmatically re-focused the input — meant to suppress iOS's visualViewport auto-scroll on re-focus. On Android, preventDefault on a textarea touchstart blocks the soft keyboard from opening (programmatic focus() alone does not raise the Android keyboard — only the default touch action does), so tapping the main chat or quick chat composer focused the input but the keyboard never appeared, looking like an instant dismiss. Gate the touchstart workaround to iOS via isIOS(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3128 lines
115 KiB
TypeScript
3128 lines
115 KiB
TypeScript
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, Hash, MessageSquare, Paperclip, Plus, Send, Square, Wrench, X } from "lucide-react";
|
||
import { attachmentBaseUrlForRoom, 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 { useModelsCache } from "../hooks/useModelsCache";
|
||
import { useDiscoveredSkillsCache } from "../hooks/useDiscoveredSkillsCache";
|
||
import { FileMentionPopup } from "./FileMentionPopup";
|
||
import { useFileMention } from "../hooks/useFileMention";
|
||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||
import { isIOS } from "../hooks/useMobileScrollLock";
|
||
import { useViewportMode } from "../hooks/useViewportMode";
|
||
import { useAppSettings } from "../hooks/useAppSettings";
|
||
import { useChatRooms } from "../hooks/useChatRooms";
|
||
import { useChatUnread } from "../hooks/useChatUnread";
|
||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||
|
||
interface PendingAttachment {
|
||
file: File;
|
||
/** Object URL for image previews; empty string for non-image attachments. */
|
||
previewUrl: string;
|
||
}
|
||
|
||
interface QuickChatRoomContext {
|
||
roomName: string;
|
||
memberIds: ReadonlySet<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;
|
||
/** Optional room context for member-aware mention UX */
|
||
roomContext?: QuickChatRoomContext | null;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
export function clampQuickChatInputHeight(scrollHeight: number): number {
|
||
// Match ChatView's 640px cap so pasted multi-paragraph text remains visible,
|
||
// while keeping an upper bound that protects message visibility on short screens.
|
||
return Math.max(40, Math.min(scrollHeight, 640));
|
||
}
|
||
|
||
function truncateToolValue(value: string, maxLength: number): string {
|
||
if (value.length <= maxLength) return value;
|
||
return `${value.slice(0, maxLength)}…`;
|
||
}
|
||
|
||
function formatToolArgsSummary(args?: Record<string, unknown>): 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 (
|
||
<details
|
||
key={`${toolCall.toolName}-${index}`}
|
||
className={`chat-tool-call${isRunning ? " chat-tool-call--running" : ""}${isError ? " chat-tool-call--error" : ""}`}
|
||
open={isRunning}
|
||
>
|
||
<summary>
|
||
<span className="chat-tool-call-status-dot" aria-hidden="true" />
|
||
<span className="chat-tool-call-name" title={toolCall.toolName}>{toolCall.toolName}</span>
|
||
{summaryPreview && <span className="chat-tool-call-preview" title={summaryPreview}>{summaryPreview}</span>}
|
||
<span className="chat-tool-call-status-text">{statusLabel}</span>
|
||
</summary>
|
||
<div className="chat-tool-call-content">
|
||
{argsSummary && (
|
||
<div className="chat-tool-call-row">
|
||
<span className="chat-tool-call-label">args</span>
|
||
<span className="chat-tool-call-value">{argsSummary}</span>
|
||
</div>
|
||
)}
|
||
{resultSummary && (
|
||
<div className={`chat-tool-call-row${isError ? " chat-tool-call-row--error" : ""}`}>
|
||
<span className="chat-tool-call-label">result</span>
|
||
<span className="chat-tool-call-value">{resultSummary}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</details>
|
||
);
|
||
};
|
||
|
||
const className = `chat-tool-calls${compact ? " chat-tool-calls--compact" : ""}`;
|
||
if (toolCalls.length === 1) {
|
||
return (
|
||
<div className={className} data-testid="chat-tool-calls">
|
||
<div className="chat-tool-calls-header">
|
||
<Wrench size={12} aria-hidden="true" />
|
||
<span>Tool calls</span>
|
||
</div>
|
||
{renderToolCallItem(toolCalls[0], 0)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className={className} data-testid="chat-tool-calls">
|
||
<details className={`chat-tool-calls-group${compact ? " chat-tool-calls-group--compact" : ""}`} data-testid="chat-tool-calls-group" open={hasRunning}>
|
||
<summary className="chat-tool-calls-group-summary">
|
||
<Wrench size={12} aria-hidden="true" />
|
||
<span className="chat-tool-calls-count">{toolCalls.length} tool calls</span>
|
||
<span className="chat-tool-calls-names" title={namesSummary}>{namesSummary}</span>
|
||
{statusSummary && <span className="chat-tool-calls-group-status">{statusSummary}</span>}
|
||
</summary>
|
||
{toolCalls.map((toolCall, index) => renderToolCallItem(toolCall, index))}
|
||
</details>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const quickChatMarkdownComponents: Components = {
|
||
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
|
||
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
|
||
pre: ({ children, ...props }) => (
|
||
<pre {...props} className="quick-chat-markdown-pre">
|
||
{children}
|
||
</pre>
|
||
),
|
||
table: ({ children, ...props }) => (
|
||
<table {...props} className="quick-chat-markdown-table">
|
||
{children}
|
||
</table>
|
||
),
|
||
};
|
||
|
||
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<boolean>) {
|
||
// 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<Position>(() => {
|
||
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<number | null>(null);
|
||
const dragTargetRef = useRef<HTMLElement | null>(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<HTMLButtonElement>) => {
|
||
// 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<PanelSize>;
|
||
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<PanelSize>(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<PanelAnchorOffset>({ 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<HTMLDivElement>) => {
|
||
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<string, Agent>;
|
||
roomContext: QuickChatRoomContext | null;
|
||
projectId?: string;
|
||
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,
|
||
roomContext,
|
||
projectId,
|
||
onToggleRender,
|
||
}: QuickChatMessageItemProps) {
|
||
const isSent = message.role === "user";
|
||
|
||
const renderedUserContent = useMemo<ReactNode>(() => {
|
||
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) {
|
||
const isNonMember = Boolean(roomContext && !roomContext.memberIds.has(mentionedAgent.id));
|
||
const nonMemberLabel = isNonMember ? `Not a member of ${roomContext?.roomName}` : undefined;
|
||
parts.push(
|
||
<span
|
||
key={`${mentionedAgent.id}-${start}`}
|
||
className={`chat-mention-chip${isNonMember ? " chat-mention-chip--non-member" : ""}`}
|
||
title={nonMemberLabel}
|
||
aria-label={nonMemberLabel}
|
||
>
|
||
@{mentionedAgent.name.replace(/\s+/g, "_")}
|
||
</span>,
|
||
);
|
||
} 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, roomContext]);
|
||
|
||
const assistantBody = useMemo<ReactNode>(() => {
|
||
if (isSent) return null;
|
||
if (forcePlain) {
|
||
return <div className="quick-chat-message-content quick-chat-message-content--plain">{linkifyFilePaths(message.content)}</div>;
|
||
}
|
||
return (
|
||
<div className="quick-chat-message-content quick-chat-message-content--markdown">
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={quickChatMarkdownComponents}>
|
||
{message.content}
|
||
</ReactMarkdown>
|
||
</div>
|
||
);
|
||
}, [isSent, forcePlain, message.content]);
|
||
|
||
const renderedAttachments = useMemo(() => {
|
||
if (!message.attachments?.length || !message.roomId) return null;
|
||
const baseUrl = attachmentBaseUrlForRoom(message.roomId, projectId);
|
||
return (
|
||
<div className="chat-message-attachments">
|
||
{message.attachments.map((attachment) => {
|
||
const href = `${baseUrl}${encodeURIComponent(attachment.filename)}`;
|
||
const isImage = attachment.mimeType.startsWith("image/");
|
||
return (
|
||
<a
|
||
key={attachment.id}
|
||
className="chat-message-attachment"
|
||
href={href}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
data-testid="quick-chat-message-attachment"
|
||
>
|
||
{isImage ? <img src={href} alt={attachment.originalName} className="chat-attachment-image" loading="lazy" /> : <span>{attachment.originalName}</span>}
|
||
</a>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}, [message.attachments, message.roomId, projectId]);
|
||
|
||
return (
|
||
<div
|
||
className={`quick-chat-panel-message ${isSent ? "quick-chat-panel-message--sent" : "quick-chat-panel-message--received"}`}
|
||
data-testid={`quick-chat-message-${message.id}`}
|
||
>
|
||
{isSent
|
||
? <p>{renderedUserContent}</p>
|
||
: (
|
||
<>
|
||
{assistantBody}
|
||
<button
|
||
type="button"
|
||
className={`quick-chat-message-render-toggle${forcePlain ? " quick-chat-message-render-toggle--plain" : ""}`}
|
||
data-testid="quick-chat-message-render-toggle"
|
||
aria-label={forcePlain ? "Show rendered markdown" : "Show plain text"}
|
||
onClick={() => onToggleRender(message.id)}
|
||
>
|
||
{forcePlain ? <EyeOff size={14} /> : <Eye size={14} />}
|
||
</button>
|
||
</>
|
||
)}
|
||
{renderedAttachments}
|
||
{renderToolCalls(message.toolCalls, true)}
|
||
</div>
|
||
);
|
||
});
|
||
|
||
export function QuickChatFAB({
|
||
projectId,
|
||
addToast,
|
||
showFAB = true,
|
||
open,
|
||
onOpenChange,
|
||
favoriteProviders = [],
|
||
favoriteModels = [],
|
||
onToggleFavorite,
|
||
onToggleModelFavorite,
|
||
roomContext = null,
|
||
}: QuickChatFABProps) {
|
||
const { agents } = useAgents(projectId);
|
||
const {
|
||
models,
|
||
defaultProvider,
|
||
defaultModelId,
|
||
loading: modelsLoading,
|
||
} = useModelsCache();
|
||
const { skills: discoveredSkills, loading: skillsLoading } = useDiscoveredSkillsCache(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.
|
||
const { keyboardOpen } = useMobileKeyboard({ enabled: isOpen });
|
||
const viewportMode = useViewportMode();
|
||
const isMobile = viewportMode === "mobile";
|
||
|
||
const [chatMode, setChatMode] = useState<"agent" | "model">("agent");
|
||
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
|
||
const [newSessionChooserOpen, setNewSessionChooserOpen] = useState(false);
|
||
const [sessionMenuOpen, setSessionMenuOpen] = useState(false);
|
||
const [newSessionMode, setNewSessionMode] = useState<"agent" | "model">("model");
|
||
const [newSessionAgentId, setNewSessionAgentId] = useState<string>("");
|
||
const [newSessionModel, setNewSessionModel] = useState<string>("");
|
||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||
const [configuredDefaultModelSelection, setConfiguredDefaultModelSelection] = useState<string>("");
|
||
const [messageInput, setMessageInput] = useState("");
|
||
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<Set<string>>(() => new Set());
|
||
const [helpMessageVisible, setHelpMessageVisible] = useState(false);
|
||
/** Pending attachments staged in the composer before being sent. */
|
||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||
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: HTMLTextAreaElement | 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 modelsInitSettledRef = useRef(false);
|
||
const prevSessionTargetRef = useRef("");
|
||
const hasAppliedInitialSessionRef = useRef(false);
|
||
const restoredFromExistingSessionRef = useRef(false);
|
||
const selectedAgentIdRef = useRef(selectedAgentId);
|
||
const selectedModelRef = useRef(selectedModel);
|
||
const mentionCursorPosRef = useRef(0);
|
||
const hideMentionPopupTimeoutRef = useRef<number | null>(null);
|
||
const hideSkillMenuTimeoutRef = useRef<number | null>(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 { experimentalFeatures } = useAppSettings();
|
||
const chatRoomsEnabled = experimentalFeatures?.chatRooms === true;
|
||
const roomsState = useChatRooms(projectId, addToast);
|
||
const { isUnread, markRead } = useChatUnread(projectId);
|
||
|
||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||
const fabRef = useRef<HTMLButtonElement | null>(null);
|
||
const messagesRef = useRef<HTMLDivElement | null>(null);
|
||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||
const sessionMenuRef = useRef<HTMLDivElement | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
|
||
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<HTMLInputElement | null>(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 <html> and <body>; 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 roomThreadActive = chatRoomsEnabled && Boolean(roomsState.activeRoom);
|
||
const displayedMessages = useMemo<ChatMessageInfo[]>(() => {
|
||
if (!roomThreadActive) {
|
||
return messages;
|
||
}
|
||
return roomsState.messages.map((message) => ({
|
||
id: message.id,
|
||
sessionId: message.roomId,
|
||
roomId: message.roomId,
|
||
role: message.role,
|
||
content: message.content,
|
||
thinkingOutput: message.thinkingOutput,
|
||
toolCalls: undefined,
|
||
attachments: message.attachments,
|
||
createdAt: message.createdAt,
|
||
}));
|
||
}, [messages, roomThreadActive, roomsState.messages]);
|
||
const inputDisabled = roomThreadActive ? false : (!hasChatTarget || !activeSession);
|
||
const sendDisabled =
|
||
(messageInput.trim().length === 0 && pendingAttachments.length === 0)
|
||
|| (!roomThreadActive && !hasChatTarget)
|
||
|| (!roomThreadActive && !activeSession);
|
||
const hasPersistedAgentSessionSelection = useMemo(
|
||
() => Boolean(selectedAgentId) && sessions.some((session) => !session.modelProvider && !session.modelId && session.agentId === selectedAgentId),
|
||
[selectedAgentId, sessions],
|
||
);
|
||
|
||
useEffect(() => {
|
||
selectedAgentIdRef.current = selectedAgentId;
|
||
}, [selectedAgentId]);
|
||
|
||
useEffect(() => {
|
||
selectedModelRef.current = selectedModel;
|
||
}, [selectedModel]);
|
||
|
||
useEffect(() => {
|
||
if (agents.length === 0) {
|
||
setSelectedAgentId("");
|
||
setChatMode("model");
|
||
return;
|
||
}
|
||
|
||
if (hasAppliedInitialSessionRef.current && hasPersistedAgentSessionSelection) {
|
||
return;
|
||
}
|
||
|
||
const selectedStillExists = agents.some((agent) => agent.id === selectedAgentId);
|
||
if (!selectedStillExists) {
|
||
setSelectedAgentId(agents[0]?.id ?? "");
|
||
}
|
||
}, [agents, hasPersistedAgentSessionSelection, selectedAgentId]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) {
|
||
return;
|
||
}
|
||
|
||
if (!modelsRequestedRef.current) {
|
||
modelsRequestedRef.current = true;
|
||
modelsInitSettledRef.current = false;
|
||
}
|
||
|
||
if (modelsLoading || !modelsRequestedRef.current || modelsInitSettledRef.current) {
|
||
return;
|
||
}
|
||
|
||
if (!selectedModelRef.current && models.length > 0) {
|
||
if (defaultProvider && defaultModelId) {
|
||
const defaultSelection = `${defaultProvider}/${defaultModelId}`;
|
||
const hasDefaultModel = models.some((model) => `${model.provider}/${model.id}` === defaultSelection);
|
||
if (hasDefaultModel) {
|
||
setConfiguredDefaultModelSelection(defaultSelection);
|
||
if (!selectedModelRef.current) {
|
||
setSelectedModel(defaultSelection);
|
||
}
|
||
if (!hasAppliedInitialSessionRef.current) {
|
||
setChatMode("model");
|
||
}
|
||
modelsInitSettledRef.current = true;
|
||
return;
|
||
}
|
||
}
|
||
|
||
setConfiguredDefaultModelSelection("");
|
||
const firstModel = models[0];
|
||
if (firstModel && !selectedModelRef.current) {
|
||
setSelectedModel(`${firstModel.provider}/${firstModel.id}`);
|
||
}
|
||
}
|
||
|
||
modelsInitSettledRef.current = true;
|
||
}, [defaultModelId, defaultProvider, isOpen, models, modelsLoading]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
void refreshSessions();
|
||
}, [isOpen, refreshSessions]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen || sessionsLoading || hasAppliedInitialSessionRef.current || sessions.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const activeSessions = sessions.filter((session) => session.status !== "archived");
|
||
const timestamp = (value?: string | null): number => {
|
||
if (!value) return 0;
|
||
const parsed = Date.parse(value);
|
||
return Number.isFinite(parsed) ? parsed : 0;
|
||
};
|
||
const latestSession = [...activeSessions].sort((a, b) => {
|
||
const aLastTouched = Math.max(timestamp(a.lastMessageAt), timestamp(a.updatedAt));
|
||
const bLastTouched = Math.max(timestamp(b.lastMessageAt), timestamp(b.updatedAt));
|
||
return bLastTouched - aLastTouched;
|
||
})[0];
|
||
|
||
if (latestSession) {
|
||
if (latestSession.modelProvider && latestSession.modelId) {
|
||
setChatMode("model");
|
||
setSelectedModel(`${latestSession.modelProvider}/${latestSession.modelId}`);
|
||
} else {
|
||
setChatMode("agent");
|
||
setSelectedAgentId(latestSession.agentId);
|
||
}
|
||
|
||
restoredFromExistingSessionRef.current = true;
|
||
void selectSession(latestSession);
|
||
} else {
|
||
restoredFromExistingSessionRef.current = false;
|
||
}
|
||
|
||
hasAppliedInitialSessionRef.current = true;
|
||
}, [isOpen, selectSession, sessions, sessionsLoading]);
|
||
|
||
// 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;
|
||
}
|
||
|
||
const waitingForInitialModelResolution = !hasAppliedInitialSessionRef.current
|
||
&& sessions.length === 0
|
||
&& modelsRequestedRef.current
|
||
&& !modelsInitSettledRef.current;
|
||
if (waitingForInitialModelResolution) {
|
||
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 (restoredFromExistingSessionRef.current) {
|
||
restoredFromExistingSessionRef.current = false;
|
||
prevSessionTargetRef.current = sessionTargetKey;
|
||
return;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
hasAppliedInitialSessionRef.current = false;
|
||
restoredFromExistingSessionRef.current = false;
|
||
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(() => {
|
||
const matchingAgents = agents.filter((agent) => matchesAgentMentionFilter(agent.name, mentionFilter));
|
||
if (!roomContext) {
|
||
return matchingAgents;
|
||
}
|
||
|
||
const memberAgents = matchingAgents.filter((agent) => roomContext.memberIds.has(agent.id));
|
||
if (mentionFilter.trim().length === 0) {
|
||
return memberAgents;
|
||
}
|
||
|
||
const otherAgents = matchingAgents.filter((agent) => !roomContext.memberIds.has(agent.id));
|
||
return [...memberAgents, ...otherAgents];
|
||
}, [agents, mentionFilter, roomContext]);
|
||
|
||
const mentionAgentsByName = useMemo(() => {
|
||
const byName = new Map<string, Agent>();
|
||
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 threadId = roomThreadActive ? (roomsState.activeRoom?.id ?? null) : (activeSession?.id ?? null);
|
||
const previousState = previousOpenStateRef.current;
|
||
previousOpenStateRef.current = { isOpen, sessionId: threadId };
|
||
|
||
if (!isOpen || !threadId) {
|
||
return;
|
||
}
|
||
|
||
const openingNow = !previousState.isOpen && isOpen;
|
||
const sessionChangedWhileOpen = previousState.isOpen && previousState.sessionId !== threadId;
|
||
if (!openingNow && !sessionChangedWhileOpen) {
|
||
return;
|
||
}
|
||
|
||
const messagesEl = messagesRef.current;
|
||
if (!messagesEl) return;
|
||
|
||
anchorToBottom(messagesEl);
|
||
}, [isOpen, activeSession?.id, anchorToBottom, roomThreadActive, roomsState.activeRoom?.id]);
|
||
|
||
useEffect(() => {
|
||
if (!isMobile || !isOpen || !activeSession) {
|
||
return;
|
||
}
|
||
|
||
const reAnchorToLatest = () => {
|
||
const messagesEl = messagesRef.current;
|
||
if (!messagesEl) {
|
||
return;
|
||
}
|
||
anchorToBottom(messagesEl);
|
||
};
|
||
|
||
const onVisibilityChange = () => {
|
||
if (document.visibilityState !== "visible") {
|
||
return;
|
||
}
|
||
reAnchorToLatest();
|
||
};
|
||
|
||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||
window.addEventListener("pageshow", reAnchorToLatest);
|
||
|
||
return () => {
|
||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||
window.removeEventListener("pageshow", reAnchorToLatest);
|
||
};
|
||
}, [isMobile, isOpen, activeSession, anchorToBottom]);
|
||
|
||
// Auto-scroll messages when user is near the live tail.
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
if (!isUserScrollingRef.current) {
|
||
scrollToBottom();
|
||
}
|
||
}, [displayedMessages, streamingText, streamingThinking, isStreaming, isOpen, roomThreadActive, scrollToBottom]);
|
||
|
||
useEffect(() => {
|
||
if (!activeSession?.id) {
|
||
return;
|
||
}
|
||
|
||
markRead("direct", activeSession.id, activeSession.lastMessageAt ?? activeSession.updatedAt);
|
||
}, [activeSession?.id, activeSession?.lastMessageAt, activeSession?.updatedAt, markRead]);
|
||
|
||
useEffect(() => {
|
||
if (!roomsState.activeRoom?.id) {
|
||
return;
|
||
}
|
||
|
||
markRead("room", roomsState.activeRoom.id, roomsState.activeRoom.updatedAt);
|
||
}, [markRead, roomsState.activeRoom?.id, roomsState.activeRoom?.updatedAt]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) {
|
||
return;
|
||
}
|
||
|
||
if (roomsState.activeRoom?.id) {
|
||
markRead("room", roomsState.activeRoom.id, roomsState.activeRoom.updatedAt);
|
||
return;
|
||
}
|
||
|
||
if (activeSession?.id) {
|
||
markRead("direct", activeSession.id, activeSession.lastMessageAt ?? activeSession.updatedAt);
|
||
}
|
||
}, [activeSession?.id, activeSession?.lastMessageAt, activeSession?.updatedAt, isOpen, markRead, roomsState.activeRoom?.id, roomsState.activeRoom?.updatedAt]);
|
||
|
||
useEffect(() => {
|
||
if (isStreaming) {
|
||
return;
|
||
}
|
||
|
||
if (roomsState.activeRoom?.id && roomsState.messages.length > 0) {
|
||
const latestRoomMessage = roomsState.messages[roomsState.messages.length - 1];
|
||
markRead("room", roomsState.activeRoom.id, latestRoomMessage?.createdAt ?? roomsState.activeRoom.updatedAt);
|
||
return;
|
||
}
|
||
|
||
if (activeSession?.id && messages.length > 0) {
|
||
const latestMessage = messages[messages.length - 1];
|
||
markRead("direct", activeSession.id, latestMessage?.createdAt ?? activeSession.lastMessageAt ?? activeSession.updatedAt);
|
||
}
|
||
}, [activeSession?.id, activeSession?.lastMessageAt, activeSession?.updatedAt, isStreaming, markRead, messages, roomsState.activeRoom?.id, roomsState.activeRoom?.updatedAt, roomsState.messages]);
|
||
|
||
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 roomOptions = useMemo(
|
||
() => (chatRoomsEnabled ? roomsState.rooms : []),
|
||
[chatRoomsEnabled, roomsState.rooms],
|
||
);
|
||
|
||
const showRoomGroups = chatRoomsEnabled && roomOptions.length > 0;
|
||
|
||
const activeSessionLabel = useMemo(() => {
|
||
if (showRoomGroups && roomThreadActive && roomsState.activeRoom) {
|
||
return `#${roomsState.activeRoom.name}`;
|
||
}
|
||
const activeOption = sessionOptions.find((option) => option.id === activeSession?.id);
|
||
if (activeOption) {
|
||
return activeOption.label;
|
||
}
|
||
if (sessionsLoading) {
|
||
return "Loading sessions…";
|
||
}
|
||
return "Select a session";
|
||
}, [activeSession?.id, roomThreadActive, roomsState.activeRoom, sessionOptions, sessionsLoading, showRoomGroups]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) {
|
||
setSessionMenuOpen(false);
|
||
}
|
||
}, [isOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!sessionMenuOpen) {
|
||
return;
|
||
}
|
||
|
||
const handleSessionMenuOutsideClick = (event: MouseEvent) => {
|
||
const target = event.target as Node;
|
||
if (sessionMenuRef.current?.contains(target)) {
|
||
return;
|
||
}
|
||
setSessionMenuOpen(false);
|
||
};
|
||
|
||
const handleSessionMenuEscape = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") {
|
||
setSessionMenuOpen(false);
|
||
}
|
||
};
|
||
|
||
document.addEventListener("mousedown", handleSessionMenuOutsideClick);
|
||
document.addEventListener("keydown", handleSessionMenuEscape);
|
||
return () => {
|
||
document.removeEventListener("mousedown", handleSessionMenuOutsideClick);
|
||
document.removeEventListener("keydown", handleSessionMenuEscape);
|
||
};
|
||
}, [sessionMenuOpen]);
|
||
|
||
const inputPlaceholder = useMemo(() => {
|
||
if (roomThreadActive && roomsState.activeRoom) {
|
||
return `Message #${roomsState.activeRoom.name}`;
|
||
}
|
||
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, roomThreadActive, roomsState.activeRoom?.name, selectedAgent, selectedModelTag]);
|
||
|
||
const handleSessionSwitch = useCallback((sessionId: string) => {
|
||
const selectedSession = sessions.find((session) => session.id === sessionId);
|
||
if (!selectedSession) {
|
||
return;
|
||
}
|
||
|
||
if (roomThreadActive) {
|
||
roomsState.selectRoom(null);
|
||
}
|
||
|
||
markRead("direct", selectedSession.id, selectedSession.lastMessageAt ?? selectedSession.updatedAt);
|
||
hasAppliedInitialSessionRef.current = true;
|
||
|
||
if (selectedSession.modelProvider && selectedSession.modelId) {
|
||
const targetKey = `${FN_AGENT_ID}::${selectedSession.modelProvider}/${selectedSession.modelId}`;
|
||
restoredFromExistingSessionRef.current = true;
|
||
prevSessionTargetRef.current = targetKey;
|
||
setChatMode("model");
|
||
setSelectedModel(`${selectedSession.modelProvider}/${selectedSession.modelId}`);
|
||
} else {
|
||
const targetKey = `${selectedSession.agentId}::`;
|
||
restoredFromExistingSessionRef.current = true;
|
||
prevSessionTargetRef.current = targetKey;
|
||
setChatMode("agent");
|
||
setSelectedAgentId(selectedSession.agentId);
|
||
}
|
||
|
||
void selectSession(selectedSession);
|
||
setSessionMenuOpen(false);
|
||
}, [markRead, roomThreadActive, roomsState, selectSession, sessions]);
|
||
|
||
const handleRoomSwitch = useCallback((roomId: string) => {
|
||
const selectedRoom = roomsState.rooms.find((room) => room.id === roomId);
|
||
markRead("room", roomId, selectedRoom?.updatedAt);
|
||
roomsState.selectRoom(roomId);
|
||
hasAppliedInitialSessionRef.current = true;
|
||
setSessionMenuOpen(false);
|
||
}, [markRead, roomsState]);
|
||
|
||
const handleCreateFreshSession = useCallback(async () => {
|
||
if (sessionsLoading) return;
|
||
|
||
hasAppliedInitialSessionRef.current = true;
|
||
|
||
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<HTMLTextAreaElement>) => {
|
||
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" || trimmed === "/new") {
|
||
attachmentsToSend.forEach((attachment) => {
|
||
if (attachment.previewUrl) {
|
||
URL.revokeObjectURL(attachment.previewUrl);
|
||
}
|
||
});
|
||
setPendingAttachments((previous) => previous.filter((attachment) => !attachmentsToSend.includes(attachment)));
|
||
|
||
try {
|
||
if (roomThreadActive && roomsState.activeRoom?.id) {
|
||
await roomsState.clearRoom(roomsState.activeRoom.id);
|
||
setHelpMessageVisible(false);
|
||
} else if (chatMode === "model") {
|
||
clearPendingMessage();
|
||
stopStreaming();
|
||
const parsed = parseModelSelection(resolvedModelSelection);
|
||
if (!parsed) {
|
||
return;
|
||
}
|
||
await startFreshSession(FN_AGENT_ID, parsed.modelProvider, parsed.modelId);
|
||
} else if (selectedAgentId) {
|
||
clearPendingMessage();
|
||
stopStreaming();
|
||
await startFreshSession(selectedAgentId);
|
||
}
|
||
} catch {
|
||
addToast("Failed to clear conversation", "error");
|
||
} finally {
|
||
focusComposerInput();
|
||
preserveComposerFocusRef.current = false;
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setHelpMessageVisible(false);
|
||
if (chatRoomsEnabled && roomsState.activeRoom) {
|
||
await roomsState.sendRoomMessage(trimmed, { files: attachmentsToSend.map((attachment) => attachment.file) });
|
||
} else {
|
||
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 (error) {
|
||
const message = error instanceof Error && error.message.trim()
|
||
? error.message
|
||
: (chatRoomsEnabled && roomsState.activeRoom ? "Failed to send room message" : "Failed to send message");
|
||
addToast(message, "error");
|
||
// Keep pending attachments on failure so user can retry.
|
||
} finally {
|
||
focusComposerInput();
|
||
preserveComposerFocusRef.current = false;
|
||
}
|
||
}, [
|
||
addToast,
|
||
chatMode,
|
||
chatRoomsEnabled,
|
||
clearPendingMessage,
|
||
focusComposerInput,
|
||
inputDisabled,
|
||
messageInput,
|
||
resolvedModelSelection,
|
||
roomThreadActive,
|
||
roomsState,
|
||
selectedAgentId,
|
||
sendMessage,
|
||
startFreshSession,
|
||
stopStreaming,
|
||
]);
|
||
|
||
const handleAttachmentDragEnter = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||
event.preventDefault();
|
||
dragDepthRef.current += 1;
|
||
setIsAttachmentDragOver(true);
|
||
}, []);
|
||
|
||
const handleAttachmentDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = "copy";
|
||
setIsAttachmentDragOver(true);
|
||
}, []);
|
||
|
||
const handleAttachmentDragLeave = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||
event.preventDefault();
|
||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||
if (dragDepthRef.current === 0) {
|
||
setIsAttachmentDragOver(false);
|
||
}
|
||
}, []);
|
||
|
||
const handleAttachmentDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||
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 resizeQuickChatComposer = useCallback((composer: HTMLTextAreaElement | null = inputRef.current) => {
|
||
if (!composer) {
|
||
return;
|
||
}
|
||
|
||
composer.style.height = "auto";
|
||
composer.style.height = `${clampQuickChatInputHeight(composer.scrollHeight)}px`;
|
||
}, []);
|
||
|
||
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;
|
||
resizeQuickChatComposer(inputRef.current);
|
||
inputRef.current.focus();
|
||
});
|
||
|
||
return nextInput;
|
||
});
|
||
|
||
setShowSkillMenu(false);
|
||
setSkillFilter("");
|
||
setHighlightedSkillIndex(0);
|
||
}, [resizeQuickChatComposer]);
|
||
|
||
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;
|
||
resizeQuickChatComposer(inputRef.current);
|
||
inputRef.current.focus();
|
||
inputRef.current.setSelectionRange(nextCursorPos, nextCursorPos);
|
||
});
|
||
},
|
||
[mentionStartPos, messageInput, resizeQuickChatComposer],
|
||
);
|
||
|
||
const insertHashMention = useCallback(
|
||
(nextInput: string, insertedToken: string) => {
|
||
const input = inputRef.current;
|
||
const cursorPos = input?.selectionStart ?? mentionCursorPosRef.current;
|
||
const mentionStart = messageInput.lastIndexOf("#", cursorPos);
|
||
const nextCursorPos = mentionStart >= 0
|
||
? mentionStart + insertedToken.length
|
||
: nextInput.length;
|
||
|
||
setMessageInput(nextInput);
|
||
fileMention.dismissMention();
|
||
setFileMentionPopupVisible(false);
|
||
|
||
window.requestAnimationFrame(() => {
|
||
if (!inputRef.current) return;
|
||
resizeQuickChatComposer(inputRef.current);
|
||
inputRef.current.focus();
|
||
inputRef.current.setSelectionRange(nextCursorPos, nextCursorPos);
|
||
});
|
||
},
|
||
[fileMention, messageInput, resizeQuickChatComposer],
|
||
);
|
||
|
||
const handleInputChange = useCallback(
|
||
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||
const nextValue = event.target.value;
|
||
const cursorPos = event.target.selectionStart ?? nextValue.length;
|
||
resizeQuickChatComposer(event.target);
|
||
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);
|
||
}
|
||
},
|
||
[fileMention, helpMessageVisible, resizeQuickChatComposer, updateFileMentionPosition, updateMentionState],
|
||
);
|
||
|
||
useLayoutEffect(() => {
|
||
resizeQuickChatComposer();
|
||
}, [messageInput, resizeQuickChatComposer]);
|
||
|
||
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<HTMLTextAreaElement>) => {
|
||
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<HTMLTextAreaElement>) => {
|
||
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 <div className="quick-chat-message-content quick-chat-message-content--plain">{linkifyFilePaths(content)}</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="quick-chat-message-content quick-chat-message-content--markdown">
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={quickChatMarkdownComponents}>
|
||
{content}
|
||
</ReactMarkdown>
|
||
</div>
|
||
);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const handleInputKeyDown = useCallback(
|
||
(event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||
mentionCursorPosRef.current = event.currentTarget.selectionStart ?? mentionCursorPosRef.current;
|
||
|
||
// Handle file mention popup keyboard navigation first
|
||
if (fileMention.mentionActive && fileMention.combinedItems.length > 0) {
|
||
fileMention.handleKeyDown(event, messageInput);
|
||
if (event.key === "Enter" || event.key === "Tab") {
|
||
const item = fileMention.combinedItems[fileMention.selectedIndex];
|
||
if (item?.kind === "task") {
|
||
insertHashMention(fileMention.selectTask(item.task, messageInput), `#${item.task.id}`);
|
||
} else if (item?.kind === "file") {
|
||
insertHashMention(fileMention.selectFile(item.file, messageInput), `#${item.file.path}`);
|
||
}
|
||
}
|
||
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,
|
||
insertHashMention,
|
||
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 (
|
||
<>
|
||
<input
|
||
ref={stealthInputRef}
|
||
type="text"
|
||
className="quick-chat-stealth-input"
|
||
aria-hidden="true"
|
||
tabIndex={-1}
|
||
/>
|
||
{showFAB && (
|
||
<button
|
||
ref={fabRef}
|
||
type="button"
|
||
className="quick-chat-fab"
|
||
aria-label="Open quick chat"
|
||
data-testid="quick-chat-fab"
|
||
data-dragging={isDragging ? "true" : "false"}
|
||
style={{ right: position.x, bottom: position.y }}
|
||
onPointerDown={handlePointerDown}
|
||
onClick={handleFABClick}
|
||
>
|
||
<MessageSquare size={24} />
|
||
</button>
|
||
)}
|
||
|
||
{isOpen && (
|
||
<div
|
||
className={`quick-chat-panel${isMobile && keyboardOpen ? " quick-chat-panel--keyboard-open" : ""}`}
|
||
ref={panelRef}
|
||
data-testid="quick-chat-panel"
|
||
style={{
|
||
...(shouldApplyDesktopPanelSize
|
||
? {
|
||
right: position.x + anchorOffset.right,
|
||
bottom: panelY + anchorOffset.bottom,
|
||
width: panelSize.width,
|
||
height: panelSize.height,
|
||
}
|
||
: {}),
|
||
}}
|
||
>
|
||
{shouldApplyDesktopPanelSize && (
|
||
<>
|
||
{/* Edge handles */}
|
||
<div
|
||
className="quick-chat-resize-handle"
|
||
data-resize-direction="n"
|
||
data-testid="quick-chat-resize-n"
|
||
onPointerDown={handleResizeStart}
|
||
role="separator"
|
||
aria-orientation="horizontal"
|
||
aria-label="Resize panel from top"
|
||
/>
|
||
<div
|
||
className="quick-chat-resize-handle"
|
||
data-resize-direction="s"
|
||
data-testid="quick-chat-resize-s"
|
||
onPointerDown={handleResizeStart}
|
||
role="separator"
|
||
aria-orientation="horizontal"
|
||
aria-label="Resize panel from bottom"
|
||
/>
|
||
<div
|
||
className="quick-chat-resize-handle"
|
||
data-resize-direction="e"
|
||
data-testid="quick-chat-resize-e"
|
||
onPointerDown={handleResizeStart}
|
||
role="separator"
|
||
aria-orientation="vertical"
|
||
aria-label="Resize panel from right"
|
||
/>
|
||
<div
|
||
className="quick-chat-resize-handle"
|
||
data-resize-direction="w"
|
||
data-testid="quick-chat-resize-w"
|
||
onPointerDown={handleResizeStart}
|
||
role="separator"
|
||
aria-orientation="vertical"
|
||
aria-label="Resize panel from left"
|
||
/>
|
||
{/* Corner handles */}
|
||
<div
|
||
className="quick-chat-resize-handle"
|
||
data-resize-direction="nw"
|
||
data-testid="quick-chat-resize-nw"
|
||
onPointerDown={handleResizeStart}
|
||
role="separator"
|
||
aria-label="Resize panel from top-left corner"
|
||
/>
|
||
<div
|
||
className="quick-chat-resize-handle"
|
||
data-resize-direction="ne"
|
||
data-testid="quick-chat-resize-ne"
|
||
onPointerDown={handleResizeStart}
|
||
role="separator"
|
||
aria-label="Resize panel from top-right corner"
|
||
/>
|
||
<div
|
||
className="quick-chat-resize-handle"
|
||
data-resize-direction="sw"
|
||
data-testid="quick-chat-resize-sw"
|
||
onPointerDown={handleResizeStart}
|
||
role="separator"
|
||
aria-label="Resize panel from bottom-left corner"
|
||
/>
|
||
<div
|
||
className="quick-chat-resize-handle"
|
||
data-resize-direction="se"
|
||
data-testid="quick-chat-resize-se"
|
||
onPointerDown={handleResizeStart}
|
||
role="separator"
|
||
aria-label="Resize panel from bottom-right corner"
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
<div className="quick-chat-panel-header">
|
||
<div className="quick-chat-panel-title-wrap">
|
||
<h3>Quick Chat</h3>
|
||
{roomThreadActive && roomsState.activeRoom ? (
|
||
<span className="quick-chat-model-tag" data-testid="quick-chat-room-tag" title={`#${roomsState.activeRoom.name}`}>
|
||
#{roomsState.activeRoom.name}
|
||
</span>
|
||
) : (
|
||
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 (
|
||
<span
|
||
className="quick-chat-model-tag quick-chat-model-tag--icon"
|
||
data-testid="quick-chat-model-tag"
|
||
title={selectedModelTag}
|
||
aria-label={selectedModelTag}
|
||
>
|
||
<ProviderIcon provider={provider} size="sm" />
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span className="quick-chat-model-tag" data-testid="quick-chat-model-tag" title={selectedModelTag}>
|
||
{selectedModelTag}
|
||
</span>
|
||
);
|
||
})()
|
||
)}
|
||
</div>
|
||
<div className="quick-chat-panel-header-actions">
|
||
<button
|
||
type="button"
|
||
className="btn-icon quick-chat-new-chat-btn"
|
||
data-testid="quick-chat-new-thread"
|
||
aria-label="Start a new chat"
|
||
onClick={handleStartFreshChat}
|
||
disabled={sessionsLoading}
|
||
>
|
||
<Plus size={16} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn-icon"
|
||
aria-label="Close quick chat"
|
||
data-testid="quick-chat-close"
|
||
onClick={() => setIsOpen(false)}
|
||
>
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="quick-chat-panel-agent-select" data-testid="quick-chat-session-select">
|
||
<div className="quick-chat-session-menu" ref={sessionMenuRef}>
|
||
<label htmlFor="quick-chat-session-dropdown-trigger" className="visually-hidden">Select session</label>
|
||
<input
|
||
type="hidden"
|
||
data-testid="quick-chat-session-dropdown"
|
||
value={showRoomGroups && roomThreadActive ? "" : activeSession?.id ?? ""}
|
||
readOnly
|
||
/>
|
||
<button
|
||
id="quick-chat-session-dropdown-trigger"
|
||
type="button"
|
||
className="btn quick-chat-session-trigger"
|
||
aria-haspopup="menu"
|
||
aria-expanded={sessionMenuOpen}
|
||
data-testid="quick-chat-session-dropdown-trigger"
|
||
onClick={() => setSessionMenuOpen((current) => !current)}
|
||
>
|
||
{roomThreadActive && roomsState.activeRoom ? (
|
||
<Hash size={16} aria-hidden="true" />
|
||
) : activeSession?.modelProvider ? (
|
||
<ProviderIcon provider={activeSession.modelProvider} size="sm" />
|
||
) : (
|
||
<MessageSquare size={16} aria-hidden="true" />
|
||
)}
|
||
<span>{activeSessionLabel}</span>
|
||
<ChevronDown size={16} aria-hidden="true" />
|
||
</button>
|
||
|
||
{sessionMenuOpen && (
|
||
<div className="quick-chat-session-dropdown" role="menu" data-testid="quick-chat-session-dropdown-menu">
|
||
{showRoomGroups && (
|
||
<>
|
||
<div className="quick-chat-session-dropdown-group-label">Rooms</div>
|
||
{roomOptions.map((room) => {
|
||
const isActiveRoom = roomsState.activeRoom?.id === room.id;
|
||
const showUnreadDot = !isActiveRoom && isUnread("room", room.id, room.updatedAt);
|
||
return (
|
||
<button
|
||
key={room.id}
|
||
type="button"
|
||
role="menuitem"
|
||
data-testid={`quick-chat-session-option-room-${room.slug}`}
|
||
className={`quick-chat-session-option${isActiveRoom ? " quick-chat-session-option--active" : ""}`}
|
||
onClick={() => handleRoomSwitch(room.id)}
|
||
>
|
||
<span>#{room.name}</span>
|
||
{showUnreadDot ? (
|
||
<span
|
||
className="chat-unread-dot quick-chat-session-unread-dot"
|
||
data-testid={`quick-chat-unread-dot-${room.id}`}
|
||
aria-label="Unread messages"
|
||
/>
|
||
) : null}
|
||
</button>
|
||
);
|
||
})}
|
||
<div className="quick-chat-session-dropdown-group-label">Sessions</div>
|
||
</>
|
||
)}
|
||
{sessionOptions.map((sessionOption) => {
|
||
const isActiveSession = !roomThreadActive && activeSession?.id === sessionOption.id;
|
||
const session = sessions.find((item) => item.id === sessionOption.id);
|
||
const showUnreadDot = !isActiveSession && isUnread("direct", sessionOption.id, session?.lastMessageAt ?? session?.updatedAt);
|
||
return (
|
||
<button
|
||
key={sessionOption.id}
|
||
type="button"
|
||
role="menuitem"
|
||
data-testid={`quick-chat-session-option-${sessionOption.id}`}
|
||
className={`quick-chat-session-option${isActiveSession ? " quick-chat-session-option--active" : ""}`}
|
||
onClick={() => handleSessionSwitch(sessionOption.id)}
|
||
>
|
||
<span>{sessionOption.label}</span>
|
||
{showUnreadDot ? (
|
||
<span
|
||
className="chat-unread-dot quick-chat-session-unread-dot"
|
||
data-testid={`quick-chat-unread-dot-${sessionOption.id}`}
|
||
aria-label="Unread messages"
|
||
/>
|
||
) : null}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{newSessionChooserOpen && (
|
||
<div className="quick-chat-new-session-chooser" data-testid="quick-chat-new-session-chooser">
|
||
<div className="quick-chat-inline-mode-toggle" data-testid="quick-chat-inline-mode-toggle">
|
||
<button
|
||
type="button"
|
||
className={`quick-chat-mode-btn${newSessionMode === "model" ? " quick-chat-mode-btn--active" : ""}`}
|
||
data-testid="quick-chat-inline-mode-model"
|
||
onClick={() => setNewSessionMode("model")}
|
||
>
|
||
Model
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`quick-chat-mode-btn${newSessionMode === "agent" ? " quick-chat-mode-btn--active" : ""}`}
|
||
data-testid="quick-chat-inline-mode-agent"
|
||
onClick={() => setNewSessionMode("agent")}
|
||
>
|
||
Agent
|
||
</button>
|
||
</div>
|
||
|
||
{newSessionMode === "agent" ? (
|
||
<div className="quick-chat-panel-agent-select">
|
||
<label htmlFor="quick-chat-new-agent-select" className="visually-hidden">Select agent for new chat</label>
|
||
<select
|
||
id="quick-chat-new-agent-select"
|
||
value={newSessionAgentId}
|
||
onChange={(event) => setNewSessionAgentId(event.target.value)}
|
||
data-testid="quick-chat-new-agent-select"
|
||
>
|
||
{agents.map((agent) => (
|
||
<option key={agent.id} value={agent.id}>{getAgentLabel(agent)}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
) : (
|
||
<div className="quick-chat-panel-agent-select" data-testid="quick-chat-new-model-select">
|
||
<CustomModelDropdown
|
||
id="quick-chat-new-model-select"
|
||
models={models}
|
||
value={newSessionModel}
|
||
onChange={setNewSessionModel}
|
||
label="Select model override"
|
||
placeholder={modelsLoading ? "Loading models…" : "Select a model"}
|
||
disabled={modelsLoading || models.length === 0}
|
||
favoriteProviders={favoriteProviders}
|
||
favoriteModels={favoriteModels}
|
||
onToggleFavorite={onToggleFavorite}
|
||
onToggleModelFavorite={onToggleModelFavorite}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div className="quick-chat-new-session-actions">
|
||
<button
|
||
type="button"
|
||
className="btn"
|
||
data-testid="quick-chat-new-session-cancel"
|
||
onClick={() => {
|
||
setNewSessionChooserOpen(false);
|
||
setNewSessionMode("model");
|
||
}}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
data-testid="quick-chat-new-session-submit"
|
||
onClick={() => void handleCreateFreshSession()}
|
||
disabled={sessionsLoading || (newSessionMode === "agent" ? !newSessionAgentId : !parseModelSelection(newSessionModel || selectedModel || configuredDefaultModelSelection))}
|
||
>
|
||
Create
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="quick-chat-panel-messages" ref={messagesRef} data-testid="quick-chat-messages" onScroll={updateScrollState}>
|
||
{sessionsLoading ? (
|
||
<div className="quick-chat-panel-empty">Loading conversation…</div>
|
||
) : !roomThreadActive && isStreaming ? (
|
||
<>
|
||
{displayedMessages.map((message: ChatMessageInfo) => (
|
||
<QuickChatMessageItem
|
||
key={message.id}
|
||
message={message}
|
||
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
|
||
mentionAgentsByName={mentionAgentsByName}
|
||
roomContext={roomContext}
|
||
projectId={projectId}
|
||
onToggleRender={toggleMessageRenderMode}
|
||
/>
|
||
))}
|
||
{helpMessageVisible && (
|
||
<div className="quick-chat-panel-message quick-chat-panel-message--received" data-testid="quick-chat-help-message">
|
||
{renderAssistantMessageContent("Available commands:\n- `/new` or `/clear` — Clear conversation and start fresh\n- `/skill:{name}` — Use a specific skill\n- `/help` — Show this help")}
|
||
</div>
|
||
)}
|
||
<div
|
||
className="quick-chat-panel-message quick-chat-panel-message--received quick-chat-panel-message--streaming"
|
||
data-testid="quick-chat-streaming-message"
|
||
>
|
||
{streamingText ? (
|
||
<>
|
||
<div data-testid="quick-chat-streaming-text">
|
||
{renderAssistantMessageContent(streamingText, plainTextMessageIds.has("__streaming__"))}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={`quick-chat-message-render-toggle${plainTextMessageIds.has("__streaming__") ? " quick-chat-message-render-toggle--plain" : ""}`}
|
||
data-testid="quick-chat-message-render-toggle"
|
||
aria-label={plainTextMessageIds.has("__streaming__") ? "Show rendered markdown" : "Show plain text"}
|
||
onClick={() => toggleMessageRenderMode("__streaming__")}
|
||
>
|
||
{plainTextMessageIds.has("__streaming__") ? <EyeOff size={14} /> : <Eye size={14} />}
|
||
</button>
|
||
</>
|
||
) : (
|
||
<p className="quick-chat-panel-waiting" data-testid="quick-chat-waiting">
|
||
{streamingThinking ? "Thinking…" : "Connecting…"}
|
||
</p>
|
||
)}
|
||
{renderToolCalls(streamingToolCalls, true)}
|
||
{streamingThinking && (
|
||
<details className="chat-message-thinking" data-testid="quick-chat-streaming-thinking">
|
||
<summary>Thinking</summary>
|
||
<pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre>
|
||
</details>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : roomThreadActive ? roomsState.messagesLoading ? (
|
||
<div className="quick-chat-panel-empty">Loading conversation…</div>
|
||
) : displayedMessages.length === 0 && !helpMessageVisible ? (
|
||
<div className="quick-chat-panel-empty">No messages yet. Start the conversation!</div>
|
||
) : (
|
||
<>
|
||
{displayedMessages.map((message: ChatMessageInfo) => (
|
||
<QuickChatMessageItem
|
||
key={message.id}
|
||
message={message}
|
||
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
|
||
mentionAgentsByName={mentionAgentsByName}
|
||
roomContext={roomContext}
|
||
projectId={projectId}
|
||
onToggleRender={toggleMessageRenderMode}
|
||
/>
|
||
))}
|
||
{helpMessageVisible && (
|
||
<div className="quick-chat-panel-message quick-chat-panel-message--received" data-testid="quick-chat-help-message">
|
||
{renderAssistantMessageContent("Available commands:\n- `/new` or `/clear` — Clear conversation and start fresh\n- `/skill:{name}` — Use a specific skill\n- `/help` — Show this help")}
|
||
</div>
|
||
)}
|
||
</>
|
||
) : messagesLoading ? (
|
||
<div className="quick-chat-panel-empty">Loading conversation…</div>
|
||
) : displayedMessages.length === 0 && !streamingText && !streamingThinking && !isStreaming && !helpMessageVisible ? (
|
||
<div className="quick-chat-panel-empty">No messages yet. Start the conversation!</div>
|
||
) : (
|
||
<>
|
||
{displayedMessages.map((message: ChatMessageInfo) => (
|
||
<QuickChatMessageItem
|
||
key={message.id}
|
||
message={message}
|
||
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
|
||
mentionAgentsByName={mentionAgentsByName}
|
||
roomContext={roomContext}
|
||
projectId={projectId}
|
||
onToggleRender={toggleMessageRenderMode}
|
||
/>
|
||
))}
|
||
{helpMessageVisible && (
|
||
<div className="quick-chat-panel-message quick-chat-panel-message--received" data-testid="quick-chat-help-message">
|
||
{renderAssistantMessageContent("Available commands:\n- `/new` or `/clear` — Clear conversation and start fresh\n- `/skill:{name}` — Use a specific skill\n- `/help` — Show this help")}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{isUserScrolling && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm quick-chat-jump-to-latest"
|
||
data-testid="quick-chat-jump-to-latest"
|
||
onClick={scrollToBottom}
|
||
>
|
||
<ChevronDown size={14} />
|
||
Latest
|
||
</button>
|
||
)}
|
||
|
||
{pendingAttachments.length > 0 && (
|
||
<div className="quick-chat-attachment-previews" data-testid="quick-chat-attachment-previews">
|
||
{pendingAttachments.map((attachment, index) => (
|
||
<div
|
||
key={`${attachment.file.name}-${index}`}
|
||
className="quick-chat-attachment-preview"
|
||
data-testid={`quick-chat-attachment-preview-${index}`}
|
||
>
|
||
{attachment.previewUrl
|
||
? <img src={attachment.previewUrl} alt={attachment.file.name} />
|
||
: <span className="quick-chat-attachment-preview-name">{attachment.file.name}</span>}
|
||
<button
|
||
type="button"
|
||
className="quick-chat-attachment-remove"
|
||
data-testid={`quick-chat-attachment-remove-${index}`}
|
||
aria-label={`Remove ${attachment.file.name}`}
|
||
onClick={() => removeAttachment(index)}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="quick-chat-panel-input">
|
||
<div
|
||
className={`quick-chat-input-wrapper${isAttachmentDragOver ? " quick-chat-input-wrapper--dragover" : ""}`}
|
||
onDragEnter={handleAttachmentDragEnter}
|
||
onDragOver={handleAttachmentDragOver}
|
||
onDragLeave={handleAttachmentDragLeave}
|
||
onDrop={handleAttachmentDrop}
|
||
>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*,.txt,.json,.yaml,.yml,.log,.csv,.xml,.md"
|
||
multiple
|
||
tabIndex={-1}
|
||
aria-hidden="true"
|
||
className="quick-chat-attachment-input"
|
||
onChange={(event) => {
|
||
handleAttachmentFiles(event.target.files);
|
||
event.target.value = "";
|
||
}}
|
||
/>
|
||
<div className="quick-chat-input-row" data-testid="quick-chat-input-row">
|
||
<button
|
||
type="button"
|
||
className="btn-icon quick-chat-attach-btn"
|
||
data-testid="quick-chat-attach-btn"
|
||
aria-label="Attach files"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
>
|
||
<Paperclip size={16} />
|
||
</button>
|
||
<textarea
|
||
ref={inputRef}
|
||
rows={1}
|
||
className="quick-chat-textarea"
|
||
value={messageInput}
|
||
onChange={handleInputChange}
|
||
onKeyDown={handleInputKeyDown}
|
||
onKeyUp={handleInputKeyUp}
|
||
onClick={handleInputSelectionChange}
|
||
onBlur={handleInputBlur}
|
||
onFocus={handleInputFocus}
|
||
onPaste={handlePaste}
|
||
// Intercept the touch *before* iOS's default focus-and-
|
||
// scroll handler runs. Without this, on the second focus
|
||
// (after a keyboard dismiss) iOS shifts the visual
|
||
// viewport to "scroll" the input into view, which yanks
|
||
// the position:fixed panel up off-screen for ~1s before
|
||
// settling back. preventDefault on touchstart suppresses
|
||
// that auto-scroll; we then focus programmatically with
|
||
// preventScroll so the keyboard still comes up.
|
||
onTouchStart={(event) => {
|
||
if (typeof window === "undefined") return;
|
||
if (window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
||
// iOS-only workaround. On Android, preventDefault on
|
||
// textarea touchstart prevents the soft keyboard from
|
||
// opening at all (programmatic focus() does not raise
|
||
// the keyboard on Android — only the default touch
|
||
// action does), so the tap silently dismisses.
|
||
if (!isIOS()) return;
|
||
if (document.activeElement === event.currentTarget) return;
|
||
event.preventDefault();
|
||
event.currentTarget.focus({ preventScroll: true });
|
||
}}
|
||
placeholder={inputPlaceholder}
|
||
disabled={inputDisabled}
|
||
data-testid="quick-chat-input"
|
||
/>
|
||
{isStreaming ? (
|
||
<button
|
||
type="button"
|
||
className="chat-input-stop quick-chat-send-btn"
|
||
onPointerDown={(event) => {
|
||
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
||
event.preventDefault();
|
||
if (event.pointerType && event.pointerType !== "mouse") {
|
||
handledMobileActionRef.current = true;
|
||
stopStreaming();
|
||
}
|
||
}}
|
||
onTouchStart={(event) => {
|
||
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
||
event.preventDefault();
|
||
handledMobileActionRef.current = true;
|
||
stopStreaming();
|
||
}}
|
||
onMouseDown={(event) => {
|
||
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
||
event.preventDefault();
|
||
}}
|
||
onClick={() => {
|
||
if (handledMobileActionRef.current) {
|
||
handledMobileActionRef.current = false;
|
||
return;
|
||
}
|
||
stopStreaming();
|
||
}}
|
||
aria-label="Stop generation"
|
||
data-testid="quick-chat-stop"
|
||
>
|
||
<Square size={14} />
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="quick-chat-send-btn"
|
||
onPointerDown={(event) => {
|
||
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
||
event.preventDefault();
|
||
if (event.pointerType && event.pointerType !== "mouse") {
|
||
handledMobileActionRef.current = true;
|
||
markPreserveComposerFocus();
|
||
focusComposerInput();
|
||
void handleSendMessage();
|
||
}
|
||
}}
|
||
onTouchStart={(event) => {
|
||
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
||
event.preventDefault();
|
||
handledMobileActionRef.current = true;
|
||
markPreserveComposerFocus();
|
||
focusComposerInput();
|
||
void handleSendMessage();
|
||
}}
|
||
onMouseDown={(event) => {
|
||
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
||
event.preventDefault();
|
||
}}
|
||
onClick={() => {
|
||
if (handledMobileActionRef.current) {
|
||
handledMobileActionRef.current = false;
|
||
return;
|
||
}
|
||
void handleSendMessage();
|
||
}}
|
||
disabled={sendDisabled}
|
||
data-testid="quick-chat-send"
|
||
>
|
||
<Send size={16} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<AgentMentionPopup
|
||
agents={agents}
|
||
filter={mentionFilter}
|
||
highlightedIndex={mentionHighlightIndex}
|
||
visible={mentionPopupVisible}
|
||
onSelect={handleMentionSelect}
|
||
position="above"
|
||
roomMemberIds={roomContext?.memberIds}
|
||
roomName={roomContext?.roomName}
|
||
/>
|
||
<FileMentionPopup
|
||
visible={fileMention.mentionActive && !mentionPopupVisible}
|
||
position={fileMentionPosition}
|
||
tasks={fileMention.tasks}
|
||
files={fileMention.files}
|
||
selectedIndex={fileMention.selectedIndex}
|
||
onSelectTask={(task) => {
|
||
insertHashMention(fileMention.selectTask(task, messageInput), `#${task.id}`);
|
||
}}
|
||
onSelectFile={(file) => {
|
||
insertHashMention(fileMention.selectFile(file, messageInput), `#${file.path}`);
|
||
}}
|
||
loading={fileMention.loading}
|
||
/>
|
||
{showSkillMenu && (
|
||
<div className="chat-skill-menu" data-testid="quick-chat-skill-menu" role="listbox" aria-label="Skill suggestions">
|
||
{skillsLoading ? (
|
||
<div className="chat-skill-menu-empty">Loading skills…</div>
|
||
) : filteredSkills.length === 0 ? (
|
||
<div className="chat-skill-menu-empty">
|
||
{skillFilter ? "No skills found" : "No skills available"}
|
||
</div>
|
||
) : (
|
||
filteredSkills.map((skill, index) => (
|
||
<button
|
||
key={skill.id}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={index === highlightedSkillIndex}
|
||
className={`chat-skill-menu-item${index === highlightedSkillIndex ? " chat-skill-menu-item--highlighted" : ""}`}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onMouseEnter={() => setHighlightedSkillIndex(index)}
|
||
onClick={() => handleSkillSelect(skill)}
|
||
>
|
||
<span className="chat-skill-menu-item-name">{skill.name}</span>
|
||
<span className="chat-skill-menu-item-description" title={skill.relativePath}>
|
||
{skill.relativePath}
|
||
</span>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
{!roomThreadActive && pendingMessage && (
|
||
<div className="chat-pending-message" data-testid="chat-pending-indicator">
|
||
<span>{`Queued: ${pendingPreview}`}</span>
|
||
<button
|
||
type="button"
|
||
className="chat-pending-message-dismiss"
|
||
aria-label="Dismiss queued message"
|
||
data-testid="chat-pending-dismiss"
|
||
onClick={clearPendingMessage}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|