Merges FN-3884 chat scroll anchoring — ChatView now re-scrolls to the latest message when reopened or when activation transitions occur, with a bounded bottom-anchor helper preventing layout-shift overshoots. QuickChatFAB was updated to wire the new anchoring behavior, and both components gained com Fusion-Task-Id: FN-3884
2661 lines
95 KiB
TypeScript
2661 lines
95 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, MessageSquare, Paperclip, Plus, Send, Square, Wrench, X } from "lucide-react";
|
||
import { fetchDiscoveredSkills, fetchModels, type Agent, type ModelInfo } from "../api";
|
||
import type { DiscoveredSkill } from "@fusion/dashboard";
|
||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||
import { ProviderIcon } from "./ProviderIcon";
|
||
import { AgentMentionPopup } from "./AgentMentionPopup";
|
||
import { matchesAgentMentionFilter } from "./mentionMatching";
|
||
import { FN_AGENT_ID, useQuickChat, type ChatMessageInfo, type ToolCallInfo } from "../hooks/useQuickChat";
|
||
import { useAgents } from "../hooks/useAgents";
|
||
import { FileMentionPopup } from "./FileMentionPopup";
|
||
import { useFileMention } from "../hooks/useFileMention";
|
||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||
import { useViewportMode } from "../hooks/useViewportMode";
|
||
|
||
interface PendingAttachment {
|
||
file: File;
|
||
/** Object URL for image previews; empty string for non-image attachments. */
|
||
previewUrl: string;
|
||
}
|
||
|
||
interface QuickChatFABProps {
|
||
projectId?: string;
|
||
addToast: (msg: string, type?: "success" | "error" | "warning") => void;
|
||
/** When false, the FAB button is hidden but the panel can still be opened programmatically via the open prop */
|
||
showFAB?: boolean;
|
||
/** When true, the chat panel is open */
|
||
open?: boolean;
|
||
/** Callback when the panel should be opened/closed */
|
||
onOpenChange?: (open: boolean) => void;
|
||
/** List of favorite provider names in preferred order */
|
||
favoriteProviders?: string[];
|
||
/** List of favorited model identifiers in format "{provider}/{modelId}" */
|
||
favoriteModels?: string[];
|
||
/** Called when user toggles a provider's favorite status */
|
||
onToggleFavorite?: (provider: string) => void;
|
||
/** Called when user toggles a model's favorite status */
|
||
onToggleModelFavorite?: (modelId: string) => void;
|
||
}
|
||
|
||
interface ParsedModelSelection {
|
||
modelProvider: string;
|
||
modelId: string;
|
||
}
|
||
|
||
function getAgentLabel(agent: Agent): string {
|
||
const base = agent.name?.trim() || agent.id;
|
||
return `${base} (${agent.role})`;
|
||
}
|
||
|
||
function parseModelSelection(selectedModel: string): ParsedModelSelection | null {
|
||
const value = selectedModel.trim();
|
||
const slashIndex = value.indexOf("/");
|
||
|
||
if (!value || slashIndex <= 0 || slashIndex >= value.length - 1) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
modelProvider: value.slice(0, slashIndex),
|
||
modelId: value.slice(slashIndex + 1),
|
||
};
|
||
}
|
||
|
||
function formatModelTagName(modelInfo: ModelInfo | null, parsedSelection: ParsedModelSelection | null): string | null {
|
||
if (!parsedSelection) {
|
||
return null;
|
||
}
|
||
|
||
if (modelInfo?.name?.trim()) {
|
||
return modelInfo.name.trim();
|
||
}
|
||
|
||
return parsedSelection.modelId
|
||
.replace(/[-_]/g, " ")
|
||
.replace(/\s+/g, " ")
|
||
.replace(/^\w/, (letter) => letter.toUpperCase())
|
||
.trim();
|
||
}
|
||
|
||
function truncateToolValue(value: string, maxLength: number): string {
|
||
if (value.length <= maxLength) return value;
|
||
return `${value.slice(0, maxLength)}…`;
|
||
}
|
||
|
||
function formatToolArgsSummary(args?: Record<string, 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 = {
|
||
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>;
|
||
onToggleRender: (id: string) => void;
|
||
}
|
||
|
||
// Memoized so streaming state churn doesn't re-render every prior message
|
||
// (each one would re-run ReactMarkdown over its full content otherwise).
|
||
const QuickChatMessageItem = memo(function QuickChatMessageItem({
|
||
message,
|
||
forcePlain,
|
||
mentionAgentsByName,
|
||
onToggleRender,
|
||
}: QuickChatMessageItemProps) {
|
||
const isSent = message.role === "user";
|
||
|
||
const renderedUserContent = useMemo<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) {
|
||
parts.push(
|
||
<span key={`${mentionedAgent.id}-${start}`} className="chat-mention-chip">
|
||
@{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]);
|
||
|
||
const assistantBody = useMemo<ReactNode>(() => {
|
||
if (isSent) return null;
|
||
if (forcePlain) {
|
||
return <div className="quick-chat-message-content quick-chat-message-content--plain">{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]);
|
||
|
||
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>
|
||
</>
|
||
)}
|
||
{renderToolCalls(message.toolCalls, true)}
|
||
</div>
|
||
);
|
||
});
|
||
|
||
export function QuickChatFAB({
|
||
projectId,
|
||
addToast,
|
||
showFAB = true,
|
||
open,
|
||
onOpenChange,
|
||
favoriteProviders = [],
|
||
favoriteModels = [],
|
||
onToggleFavorite,
|
||
onToggleModelFavorite,
|
||
}: QuickChatFABProps) {
|
||
const { agents } = useAgents(projectId);
|
||
// Internal state for uncontrolled mode, controlled state when open prop is provided
|
||
const [internalOpen, setInternalOpen] = useState(false);
|
||
const isControlled = open !== undefined;
|
||
const isOpen = isControlled ? open : internalOpen;
|
||
const setIsOpen = isControlled
|
||
? (value: boolean | ((prev: boolean) => boolean)) => {
|
||
if (typeof value === "function") {
|
||
onOpenChange?.(value(isOpen));
|
||
} else {
|
||
onOpenChange?.(value);
|
||
}
|
||
}
|
||
: setInternalOpen;
|
||
|
||
// We still consume keyboardOpen for layout decisions outside the panel,
|
||
// but the high-frequency --vv-offset-top / --vv-height tracking is set
|
||
// directly on the panel DOM in a layout effect below — going through
|
||
// React state introduces a per-event reconciliation lag that the human
|
||
// eye reads as jank while the iOS keyboard is animating in.
|
||
useMobileKeyboard({ enabled: isOpen });
|
||
const viewportMode = useViewportMode();
|
||
|
||
const [chatMode, setChatMode] = useState<"agent" | "model">("agent");
|
||
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
|
||
const [newSessionChooserOpen, setNewSessionChooserOpen] = useState(false);
|
||
const [newSessionMode, setNewSessionMode] = useState<"agent" | "model">("model");
|
||
const [newSessionAgentId, setNewSessionAgentId] = useState<string>("");
|
||
const [newSessionModel, setNewSessionModel] = useState<string>("");
|
||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||
const [modelsLoading, setModelsLoading] = useState(false);
|
||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||
const [configuredDefaultModelSelection, setConfiguredDefaultModelSelection] = useState<string>("");
|
||
const [messageInput, setMessageInput] = useState("");
|
||
const [discoveredSkills, setDiscoveredSkills] = useState<DiscoveredSkill[]>([]);
|
||
const [skillsLoading, setSkillsLoading] = useState(false);
|
||
const [showSkillMenu, setShowSkillMenu] = useState(false);
|
||
const [skillFilter, setSkillFilter] = useState("");
|
||
const [highlightedSkillIndex, setHighlightedSkillIndex] = useState(0);
|
||
const [mentionFilter, setMentionFilter] = useState("");
|
||
const [mentionPopupVisible, setMentionPopupVisible] = useState(false);
|
||
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
|
||
const [mentionStartPos, setMentionStartPos] = useState(-1);
|
||
const [plainTextMessageIds, setPlainTextMessageIds] = useState<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: HTMLInputElement | null) => {
|
||
if (!input || !fileMention.mentionActive) return;
|
||
|
||
// Get input position
|
||
const rect = input.getBoundingClientRect();
|
||
|
||
// Position above the input, using viewport coordinates
|
||
// The popup is absolutely positioned, so we use window coordinates
|
||
setFileMentionPosition({
|
||
top: rect.top - 260, // Popup appears above with gap (accounting for popup height)
|
||
left: rect.left + 8, // Small left offset
|
||
});
|
||
}, [fileMention.mentionActive]);
|
||
|
||
// Track if we just finished a drag (to prevent click from firing after drag)
|
||
const didDragRef = useRef(false);
|
||
const modelsRequestedRef = useRef(false);
|
||
const prevSessionTargetRef = useRef("");
|
||
const mentionCursorPosRef = useRef(0);
|
||
const hideMentionPopupTimeoutRef = useRef<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 panelRef = useRef<HTMLDivElement | null>(null);
|
||
const fabRef = useRef<HTMLButtonElement | null>(null);
|
||
const messagesRef = useRef<HTMLDivElement | null>(null);
|
||
const inputRef = useRef<HTMLInputElement | 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 inputDisabled = !hasChatTarget || !activeSession;
|
||
|
||
useEffect(() => {
|
||
if (agents.length === 0) {
|
||
setSelectedAgentId("");
|
||
setChatMode("model");
|
||
return;
|
||
}
|
||
|
||
const selectedStillExists = agents.some((agent) => agent.id === selectedAgentId);
|
||
if (!selectedStillExists) {
|
||
setSelectedAgentId(agents[0]?.id ?? "");
|
||
}
|
||
}, [agents, selectedAgentId]);
|
||
|
||
// Lazy-load models on first panel open.
|
||
useEffect(() => {
|
||
if (!isOpen || modelsRequestedRef.current) {
|
||
return;
|
||
}
|
||
|
||
modelsRequestedRef.current = true;
|
||
setModelsLoading(true);
|
||
|
||
fetchModels()
|
||
.then((response) => {
|
||
const loadedModels = response.models ?? [];
|
||
setModels(loadedModels);
|
||
|
||
if (selectedModel || loadedModels.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const defaultProvider = response.defaultProvider;
|
||
const defaultModelId = response.defaultModelId;
|
||
if (defaultProvider && defaultModelId) {
|
||
const defaultSelection = `${defaultProvider}/${defaultModelId}`;
|
||
const hasDefaultModel = loadedModels.some(
|
||
(model) => `${model.provider}/${model.id}` === defaultSelection,
|
||
);
|
||
if (hasDefaultModel) {
|
||
setConfiguredDefaultModelSelection(defaultSelection);
|
||
setSelectedModel(defaultSelection);
|
||
// Switch to model mode regardless of whether agents are present —
|
||
// a configured default model is an explicit user preference and
|
||
// should drive the panel to its corresponding mode immediately,
|
||
// otherwise the tag/dropdown auto-selection would be invisible
|
||
// until the user manually toggles modes.
|
||
setChatMode("model");
|
||
return;
|
||
}
|
||
}
|
||
|
||
setConfiguredDefaultModelSelection("");
|
||
|
||
// Always pre-select the first model so users can start chatting in model mode
|
||
// without having to manually pick from the dropdown.
|
||
const firstModel = loadedModels[0];
|
||
if (firstModel) {
|
||
setSelectedModel(`${firstModel.provider}/${firstModel.id}`);
|
||
}
|
||
})
|
||
.catch((error: unknown) => {
|
||
console.error("[QuickChatFAB] Failed to load models:", error);
|
||
setModels([]);
|
||
setConfiguredDefaultModelSelection("");
|
||
})
|
||
.finally(() => {
|
||
setModelsLoading(false);
|
||
});
|
||
}, [isOpen, agents.length, selectedModel]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen || !projectId) {
|
||
return;
|
||
}
|
||
|
||
setSkillsLoading(true);
|
||
fetchDiscoveredSkills(projectId)
|
||
.then((skills) => {
|
||
setDiscoveredSkills(skills);
|
||
})
|
||
.catch(() => {
|
||
setDiscoveredSkills([]);
|
||
})
|
||
.finally(() => {
|
||
setSkillsLoading(false);
|
||
});
|
||
}, [isOpen, projectId]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
void refreshSessions();
|
||
}, [isOpen, refreshSessions]);
|
||
|
||
// Initialize/switch quick chat session whenever the selected target changes.
|
||
// NOTE: activeSession and sessionsLoading are in the dependency array to
|
||
// enable retry-when-null (see shouldRetrySessionInit), but the hook's
|
||
// switchSession now reads activeSession from a ref so it doesn't get a
|
||
// new identity on every activeSession change.
|
||
useEffect(() => {
|
||
if (!isOpen) {
|
||
prevSessionTargetRef.current = "";
|
||
return;
|
||
}
|
||
|
||
if (!sessionTargetKey) {
|
||
prevSessionTargetRef.current = "";
|
||
return;
|
||
}
|
||
|
||
// When startFreshSession is in progress, skip the automatic init to
|
||
// prevent racing with the explicit fresh-session creation. Record the
|
||
// target key as "seen" so a later render won't re-trigger for the same
|
||
// target.
|
||
if (skipNextSessionInitRef.current) {
|
||
prevSessionTargetRef.current = sessionTargetKey;
|
||
return;
|
||
}
|
||
|
||
const shouldRetrySessionInit = sessionTargetKey === prevSessionTargetRef.current
|
||
&& !activeSession
|
||
&& !sessionsLoading;
|
||
|
||
if (sessionTargetKey === prevSessionTargetRef.current && !shouldRetrySessionInit) {
|
||
return;
|
||
}
|
||
|
||
prevSessionTargetRef.current = sessionTargetKey;
|
||
|
||
if (chatMode === "model" && targetModelSelection) {
|
||
void startModelChat(targetModelSelection.modelProvider, targetModelSelection.modelId);
|
||
return;
|
||
}
|
||
|
||
if (chatMode === "agent" && selectedAgentId) {
|
||
void switchSession(selectedAgentId);
|
||
}
|
||
}, [
|
||
isOpen,
|
||
chatMode,
|
||
targetModelSelection,
|
||
selectedAgentId,
|
||
sessionTargetKey,
|
||
activeSession,
|
||
sessionsLoading,
|
||
startModelChat,
|
||
switchSession,
|
||
skipNextSessionInitRef,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
return;
|
||
}
|
||
|
||
setMentionPopupVisible(false);
|
||
setMentionFilter("");
|
||
setMentionStartPos(-1);
|
||
setShowSkillMenu(false);
|
||
setSkillFilter("");
|
||
setHighlightedSkillIndex(0);
|
||
pendingAttachmentsRef.current.forEach((attachment) => {
|
||
if (attachment.previewUrl) {
|
||
URL.revokeObjectURL(attachment.previewUrl);
|
||
}
|
||
});
|
||
setPendingAttachments([]);
|
||
}, [isOpen]);
|
||
|
||
useEffect(() => {
|
||
pendingAttachmentsRef.current = pendingAttachments;
|
||
}, [pendingAttachments]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) {
|
||
shouldAutoFocusComposerRef.current = false;
|
||
return;
|
||
}
|
||
|
||
if (typeof window === "undefined") {
|
||
return;
|
||
}
|
||
|
||
shouldAutoFocusComposerRef.current = window.innerWidth <= QUICK_CHAT_DESKTOP_BREAKPOINT;
|
||
}, [isOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen || inputDisabled || !shouldAutoFocusComposerRef.current) {
|
||
return;
|
||
}
|
||
|
||
const input = inputRef.current;
|
||
if (!input) {
|
||
return;
|
||
}
|
||
|
||
const activeElement = document.activeElement;
|
||
const panelContainsFocus = activeElement ? panelRef.current?.contains(activeElement) : false;
|
||
const isBodyFocused = activeElement === document.body;
|
||
const stealthIsFocused = activeElement === stealthInputRef.current;
|
||
|
||
if (!panelContainsFocus && !isBodyFocused && !stealthIsFocused) {
|
||
shouldAutoFocusComposerRef.current = false;
|
||
return;
|
||
}
|
||
|
||
// When the stealth input is currently holding the iOS keyboard, transfer
|
||
// focus synchronously — going through requestAnimationFrame breaks the
|
||
// keyboard handoff on Safari and the keyboard dismisses.
|
||
if (stealthIsFocused) {
|
||
input.focus({ preventScroll: true });
|
||
shouldAutoFocusComposerRef.current = false;
|
||
return;
|
||
}
|
||
|
||
const frame = requestAnimationFrame(() => {
|
||
input.focus();
|
||
shouldAutoFocusComposerRef.current = false;
|
||
});
|
||
|
||
return () => cancelAnimationFrame(frame);
|
||
}, [isOpen, inputDisabled]);
|
||
|
||
// Attachment object URLs must be revoked when the composer unmounts.
|
||
useEffect(() => {
|
||
return () => {
|
||
pendingAttachmentsRef.current.forEach((attachment) => {
|
||
if (attachment.previewUrl) {
|
||
URL.revokeObjectURL(attachment.previewUrl);
|
||
}
|
||
});
|
||
};
|
||
}, []);
|
||
|
||
const handleStartFreshChat = useCallback(() => {
|
||
setNewSessionChooserOpen(true);
|
||
setNewSessionMode("model");
|
||
setNewSessionAgentId(agents[0]?.id ?? "");
|
||
setNewSessionModel(selectedModel || configuredDefaultModelSelection || "");
|
||
}, [agents, configuredDefaultModelSelection, selectedModel]);
|
||
|
||
const selectedAgent = useMemo(
|
||
() => agents.find((agent) => agent.id === selectedAgentId) ?? null,
|
||
[agents, selectedAgentId],
|
||
);
|
||
|
||
const filteredSkills = useMemo(() => {
|
||
const normalizedFilter = skillFilter.trim().toLowerCase();
|
||
const matchingSkills = normalizedFilter
|
||
? discoveredSkills.filter((skill) => skill.name.toLowerCase().includes(normalizedFilter))
|
||
: discoveredSkills;
|
||
return matchingSkills.slice(0, 10);
|
||
}, [discoveredSkills, skillFilter]);
|
||
|
||
const filteredMentionAgents = useMemo(
|
||
() => agents.filter((agent) => matchesAgentMentionFilter(agent.name, mentionFilter)),
|
||
[agents, mentionFilter],
|
||
);
|
||
|
||
const mentionAgentsByName = useMemo(() => {
|
||
const byName = new Map<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 sessionId = activeSession?.id ?? null;
|
||
const previousState = previousOpenStateRef.current;
|
||
previousOpenStateRef.current = { isOpen, sessionId };
|
||
|
||
if (!isOpen || !sessionId) {
|
||
return;
|
||
}
|
||
|
||
const openingNow = !previousState.isOpen && isOpen;
|
||
const sessionChangedWhileOpen = previousState.isOpen && previousState.sessionId !== sessionId;
|
||
if (!openingNow && !sessionChangedWhileOpen) {
|
||
return;
|
||
}
|
||
|
||
const messagesEl = messagesRef.current;
|
||
if (!messagesEl) return;
|
||
|
||
anchorToBottom(messagesEl);
|
||
}, [isOpen, activeSession?.id, anchorToBottom]);
|
||
|
||
// Auto-scroll messages when user is near the live tail.
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
if (!isUserScrollingRef.current) {
|
||
scrollToBottom();
|
||
}
|
||
}, [messages, streamingText, streamingThinking, isStreaming, isOpen, scrollToBottom]);
|
||
|
||
const sessionOptions = useMemo(() => {
|
||
const agentNameById = new Map(agents.map((agent) => [agent.id, agent.name?.trim() || agent.id]));
|
||
const modelNameByKey = new Map(
|
||
models.map((model) => [`${model.provider}/${model.id}`, model.name?.trim() || ""]),
|
||
);
|
||
|
||
return sessions.map((session, index) => {
|
||
const baseLabel = session.title?.trim() || `Session ${index + 1}`;
|
||
|
||
let descriptor: string | null = null;
|
||
if (session.agentId && session.agentId !== FN_AGENT_ID) {
|
||
descriptor = agentNameById.get(session.agentId) || session.agentId;
|
||
} else if (session.modelProvider && session.modelId) {
|
||
const modelKey = `${session.modelProvider}/${session.modelId}`;
|
||
const modelName = modelNameByKey.get(modelKey);
|
||
descriptor = modelName ? `${modelName} [${modelKey}]` : modelKey;
|
||
}
|
||
|
||
return {
|
||
id: session.id,
|
||
label: descriptor ? `${baseLabel} — ${descriptor}` : baseLabel,
|
||
};
|
||
});
|
||
}, [agents, models, sessions]);
|
||
|
||
const inputPlaceholder = useMemo(() => {
|
||
if (chatMode === "agent") {
|
||
if (selectedAgent) {
|
||
return `Message ${selectedAgent.name || selectedAgent.id}`;
|
||
}
|
||
return "Select an agent to start chatting";
|
||
}
|
||
// model mode
|
||
if (selectedModelTag) {
|
||
return `Message ${selectedModelTag}`;
|
||
}
|
||
return "Select a model to start chatting";
|
||
}, [chatMode, selectedAgent, selectedModelTag]);
|
||
|
||
const handleSessionSwitch = useCallback((sessionId: string) => {
|
||
const selectedSession = sessions.find((session) => session.id === sessionId);
|
||
if (!selectedSession) {
|
||
return;
|
||
}
|
||
|
||
if (selectedSession.modelProvider && selectedSession.modelId) {
|
||
setChatMode("model");
|
||
setSelectedModel(`${selectedSession.modelProvider}/${selectedSession.modelId}`);
|
||
} else {
|
||
setChatMode("agent");
|
||
setSelectedAgentId(selectedSession.agentId);
|
||
}
|
||
|
||
void selectSession(selectedSession);
|
||
}, [selectSession, sessions]);
|
||
|
||
const handleCreateFreshSession = useCallback(async () => {
|
||
if (sessionsLoading) return;
|
||
|
||
if (newSessionMode === "agent") {
|
||
if (!newSessionAgentId) return;
|
||
setChatMode("agent");
|
||
setSelectedAgentId(newSessionAgentId);
|
||
await startFreshSession(newSessionAgentId);
|
||
} else {
|
||
const parsed = parseModelSelection(newSessionModel || selectedModel || configuredDefaultModelSelection);
|
||
if (!parsed) return;
|
||
setChatMode("model");
|
||
setSelectedModel(`${parsed.modelProvider}/${parsed.modelId}`);
|
||
await startFreshSession(FN_AGENT_ID, parsed.modelProvider, parsed.modelId);
|
||
}
|
||
|
||
await refreshSessions();
|
||
setNewSessionChooserOpen(false);
|
||
setNewSessionMode("model");
|
||
}, [
|
||
configuredDefaultModelSelection,
|
||
newSessionAgentId,
|
||
newSessionMode,
|
||
newSessionModel,
|
||
refreshSessions,
|
||
selectedModel,
|
||
sessionsLoading,
|
||
startFreshSession,
|
||
]);
|
||
|
||
const pendingPreview = pendingMessage.length > 50
|
||
? `${pendingMessage.slice(0, 50)}…`
|
||
: pendingMessage;
|
||
|
||
/**
|
||
* Capture file selections from picker, paste, or drop and stage them in composer state.
|
||
*/
|
||
const handleAttachmentFiles = useCallback((files: FileList | null | undefined) => {
|
||
if (!files || files.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const newAttachments: PendingAttachment[] = [];
|
||
for (let index = 0; index < files.length; index += 1) {
|
||
const file = files[index];
|
||
if (!isAllowedAttachment(file)) {
|
||
continue;
|
||
}
|
||
|
||
newAttachments.push({
|
||
file,
|
||
previewUrl: isImageAttachment(file) ? URL.createObjectURL(file) : "",
|
||
});
|
||
}
|
||
|
||
if (newAttachments.length > 0) {
|
||
setPendingAttachments((previous) => [...previous, ...newAttachments]);
|
||
}
|
||
}, []);
|
||
|
||
const removeAttachment = useCallback((index: number) => {
|
||
setPendingAttachments((previous) => {
|
||
const removed = previous[index];
|
||
if (removed?.previewUrl) {
|
||
URL.revokeObjectURL(removed.previewUrl);
|
||
}
|
||
return previous.filter((_, attachmentIndex) => attachmentIndex !== index);
|
||
});
|
||
}, []);
|
||
|
||
const handlePaste = useCallback((event: React.ClipboardEvent<HTMLInputElement>) => {
|
||
handleAttachmentFiles(event.clipboardData?.files);
|
||
}, [handleAttachmentFiles]);
|
||
|
||
const focusComposerInput = useCallback(() => {
|
||
if (typeof window === "undefined") return;
|
||
if (window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
||
const input = inputRef.current;
|
||
if (!input || input.disabled) return;
|
||
input.focus({ preventScroll: true });
|
||
}, []);
|
||
|
||
const markPreserveComposerFocus = useCallback(() => {
|
||
if (typeof window === "undefined") return;
|
||
if (window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
||
preserveComposerFocusRef.current = true;
|
||
}, []);
|
||
|
||
const handleSendMessage = useCallback(async () => {
|
||
const trimmed = messageInput.trim();
|
||
const attachmentsToSend = pendingAttachmentsRef.current;
|
||
if (!trimmed && attachmentsToSend.length === 0) return;
|
||
if (inputDisabled) return;
|
||
|
||
setMessageInput("");
|
||
setMentionPopupVisible(false);
|
||
setMentionFilter("");
|
||
setMentionStartPos(-1);
|
||
|
||
if (trimmed === "/help") {
|
||
setHelpMessageVisible(true);
|
||
focusComposerInput();
|
||
preserveComposerFocusRef.current = false;
|
||
return;
|
||
}
|
||
|
||
if (trimmed === "/clear") {
|
||
stopStreaming();
|
||
clearPendingMessage();
|
||
attachmentsToSend.forEach((attachment) => {
|
||
if (attachment.previewUrl) {
|
||
URL.revokeObjectURL(attachment.previewUrl);
|
||
}
|
||
});
|
||
setPendingAttachments((previous) => previous.filter((attachment) => !attachmentsToSend.includes(attachment)));
|
||
|
||
try {
|
||
if (chatMode === "model") {
|
||
const parsed = parseModelSelection(resolvedModelSelection);
|
||
if (!parsed) {
|
||
return;
|
||
}
|
||
await startFreshSession(FN_AGENT_ID, parsed.modelProvider, parsed.modelId);
|
||
} else if (selectedAgentId) {
|
||
await startFreshSession(selectedAgentId);
|
||
}
|
||
} catch {
|
||
addToast("Failed to clear conversation", "error");
|
||
} finally {
|
||
focusComposerInput();
|
||
preserveComposerFocusRef.current = false;
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setHelpMessageVisible(false);
|
||
await sendMessage(trimmed, attachmentsToSend.map((attachment) => attachment.file));
|
||
attachmentsToSend.forEach((attachment) => {
|
||
if (attachment.previewUrl) {
|
||
URL.revokeObjectURL(attachment.previewUrl);
|
||
}
|
||
});
|
||
setPendingAttachments((previous) => previous.filter((attachment) => !attachmentsToSend.includes(attachment)));
|
||
} catch {
|
||
// Keep pending attachments on failure so user can retry.
|
||
} finally {
|
||
focusComposerInput();
|
||
preserveComposerFocusRef.current = false;
|
||
}
|
||
}, [
|
||
addToast,
|
||
chatMode,
|
||
clearPendingMessage,
|
||
focusComposerInput,
|
||
inputDisabled,
|
||
messageInput,
|
||
resolvedModelSelection,
|
||
selectedAgentId,
|
||
sendMessage,
|
||
startFreshSession,
|
||
stopStreaming,
|
||
]);
|
||
|
||
const handleAttachmentDragEnter = useCallback((event: React.DragEvent<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 handleSkillSelect = useCallback((skill: DiscoveredSkill) => {
|
||
setMessageInput((currentInput) => {
|
||
const triggerMatch = getSkillTriggerMatch(currentInput);
|
||
if (!triggerMatch) {
|
||
return currentInput;
|
||
}
|
||
|
||
const replacement = `/skill:${skill.name} `;
|
||
const nextInput = currentInput.slice(0, triggerMatch.start) + replacement + currentInput.slice(triggerMatch.end);
|
||
|
||
window.requestAnimationFrame(() => {
|
||
if (!inputRef.current) return;
|
||
inputRef.current.focus();
|
||
});
|
||
|
||
return nextInput;
|
||
});
|
||
|
||
setShowSkillMenu(false);
|
||
setSkillFilter("");
|
||
setHighlightedSkillIndex(0);
|
||
}, []);
|
||
|
||
const handleMentionSelect = useCallback(
|
||
(agent: Agent) => {
|
||
const input = inputRef.current;
|
||
if (!input || mentionStartPos < 0) {
|
||
return;
|
||
}
|
||
|
||
const selectionStart = input.selectionStart ?? mentionCursorPosRef.current;
|
||
const selectionEnd = input.selectionEnd ?? selectionStart;
|
||
const cursorPos = Math.max(selectionStart, selectionEnd);
|
||
const safeStart = Math.min(mentionStartPos, cursorPos);
|
||
const mentionText = `@${agent.name.replace(/\s+/g, "_")}`;
|
||
const replacement = `${mentionText} `;
|
||
const nextInput = messageInput.slice(0, safeStart) + replacement + messageInput.slice(cursorPos);
|
||
const nextCursorPos = safeStart + replacement.length;
|
||
|
||
setMessageInput(nextInput);
|
||
setMentionPopupVisible(false);
|
||
setMentionFilter("");
|
||
setMentionHighlightIndex(0);
|
||
setMentionStartPos(-1);
|
||
|
||
window.requestAnimationFrame(() => {
|
||
if (!inputRef.current) return;
|
||
inputRef.current.focus();
|
||
inputRef.current.setSelectionRange(nextCursorPos, nextCursorPos);
|
||
});
|
||
},
|
||
[mentionStartPos, messageInput],
|
||
);
|
||
|
||
const handleInputChange = useCallback(
|
||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const nextValue = event.target.value;
|
||
const cursorPos = event.target.selectionStart ?? nextValue.length;
|
||
mentionCursorPosRef.current = cursorPos;
|
||
setMessageInput(nextValue);
|
||
if (helpMessageVisible && nextValue.trim().length > 0) {
|
||
setHelpMessageVisible(false);
|
||
}
|
||
updateMentionState(nextValue, cursorPos);
|
||
|
||
const skillTriggerMatch = getSkillTriggerMatch(nextValue);
|
||
if (skillTriggerMatch) {
|
||
setShowSkillMenu(true);
|
||
setSkillFilter(skillTriggerMatch.filter);
|
||
} else {
|
||
setShowSkillMenu(false);
|
||
setSkillFilter("");
|
||
}
|
||
|
||
// Detect file mentions
|
||
fileMention.detectMention(nextValue, cursorPos);
|
||
setFileMentionPopupVisible(fileMention.mentionActive);
|
||
if (fileMention.mentionActive) {
|
||
updateFileMentionPosition(event.target);
|
||
}
|
||
},
|
||
[helpMessageVisible, updateMentionState, fileMention, updateFileMentionPosition],
|
||
);
|
||
|
||
const handleInputBlur = useCallback(() => {
|
||
if (preserveComposerFocusRef.current) {
|
||
window.requestAnimationFrame(() => {
|
||
focusComposerInput();
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Pre-grow the panel ahead of iOS's keyboard dismiss animation so the
|
||
// user sees the panel snap to full height immediately instead of
|
||
// following the keyboard slide-down. The suppress flag prevents the
|
||
// visualViewport listener from clobbering this with mid-dismiss
|
||
// reports while iOS is still animating the keyboard out.
|
||
if (
|
||
typeof window !== "undefined"
|
||
&& window.innerWidth <= QUICK_CHAT_DESKTOP_BREAKPOINT
|
||
&& panelRef.current
|
||
) {
|
||
suppressVvShrinkRef.current = true;
|
||
panelRef.current.style.removeProperty("--vv-height");
|
||
panelRef.current.style.removeProperty("--vv-offset-top");
|
||
window.setTimeout(() => {
|
||
suppressVvShrinkRef.current = false;
|
||
}, 450);
|
||
}
|
||
|
||
if (hideMentionPopupTimeoutRef.current !== null) {
|
||
window.clearTimeout(hideMentionPopupTimeoutRef.current);
|
||
}
|
||
|
||
hideMentionPopupTimeoutRef.current = window.setTimeout(() => {
|
||
setMentionPopupVisible(false);
|
||
setMentionFilter("");
|
||
setMentionStartPos(-1);
|
||
setFileMentionPopupVisible(false);
|
||
fileMention.dismissMention();
|
||
hideMentionPopupTimeoutRef.current = null;
|
||
}, 120);
|
||
|
||
if (hideSkillMenuTimeoutRef.current !== null) {
|
||
window.clearTimeout(hideSkillMenuTimeoutRef.current);
|
||
}
|
||
|
||
hideSkillMenuTimeoutRef.current = window.setTimeout(() => {
|
||
setShowSkillMenu(false);
|
||
hideSkillMenuTimeoutRef.current = null;
|
||
}, 120);
|
||
}, [fileMention, focusComposerInput]);
|
||
|
||
const handleInputFocus = useCallback(() => {
|
||
// Re-enable visualViewport tracking — the suppress flag set on blur
|
||
// would otherwise still be in effect if the user re-focused inside
|
||
// the suppress window.
|
||
suppressVvShrinkRef.current = false;
|
||
if (hideMentionPopupTimeoutRef.current !== null) {
|
||
window.clearTimeout(hideMentionPopupTimeoutRef.current);
|
||
hideMentionPopupTimeoutRef.current = null;
|
||
}
|
||
if (hideSkillMenuTimeoutRef.current !== null) {
|
||
window.clearTimeout(hideSkillMenuTimeoutRef.current);
|
||
hideSkillMenuTimeoutRef.current = null;
|
||
}
|
||
}, []);
|
||
|
||
const handleInputSelectionChange = useCallback(
|
||
(event: React.SyntheticEvent<HTMLInputElement>) => {
|
||
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<HTMLInputElement>) => {
|
||
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">{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<HTMLInputElement>) => {
|
||
mentionCursorPosRef.current = event.currentTarget.selectionStart ?? mentionCursorPosRef.current;
|
||
|
||
// Handle file mention popup keyboard navigation first
|
||
if (fileMention.mentionActive && fileMention.files.length > 0) {
|
||
fileMention.handleKeyDown(event, messageInput);
|
||
if (event.key === "Enter" || event.key === "Tab") {
|
||
// Select the highlighted file
|
||
const file = fileMention.files[fileMention.selectedIndex];
|
||
if (file) {
|
||
const newText = fileMention.selectFile(file, messageInput);
|
||
setMessageInput(newText);
|
||
fileMention.dismissMention();
|
||
setFileMentionPopupVisible(false);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (mentionPopupVisible && event.key === "ArrowDown") {
|
||
event.preventDefault();
|
||
if (filteredMentionAgents.length > 0) {
|
||
setMentionHighlightIndex((prev) => (prev + 1) % filteredMentionAgents.length);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (mentionPopupVisible && event.key === "ArrowUp") {
|
||
event.preventDefault();
|
||
if (filteredMentionAgents.length > 0) {
|
||
setMentionHighlightIndex((prev) =>
|
||
prev === 0 ? filteredMentionAgents.length - 1 : prev - 1,
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (mentionPopupVisible && event.key === "Enter") {
|
||
event.preventDefault();
|
||
const agentToSelect = filteredMentionAgents[mentionHighlightIndex] ?? filteredMentionAgents[0];
|
||
if (agentToSelect) {
|
||
handleMentionSelect(agentToSelect);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (mentionPopupVisible && event.key === "Escape") {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setMentionPopupVisible(false);
|
||
setMentionFilter("");
|
||
setMentionStartPos(-1);
|
||
return;
|
||
}
|
||
|
||
if (showSkillMenu && filteredSkills.length > 0 && event.key === "ArrowDown") {
|
||
event.preventDefault();
|
||
setHighlightedSkillIndex((prev) => (prev + 1) % filteredSkills.length);
|
||
return;
|
||
}
|
||
|
||
if (showSkillMenu && filteredSkills.length > 0 && event.key === "ArrowUp") {
|
||
event.preventDefault();
|
||
setHighlightedSkillIndex((prev) => (prev === 0 ? filteredSkills.length - 1 : prev - 1));
|
||
return;
|
||
}
|
||
|
||
if (showSkillMenu && (event.key === "Enter" || event.key === "Tab")) {
|
||
event.preventDefault();
|
||
const selectedSkill = filteredSkills[highlightedSkillIndex] ?? filteredSkills[0];
|
||
if (selectedSkill) {
|
||
handleSkillSelect(selectedSkill);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (showSkillMenu && event.key === "Escape") {
|
||
event.preventDefault();
|
||
setShowSkillMenu(false);
|
||
setSkillFilter("");
|
||
return;
|
||
}
|
||
|
||
if (event.key !== "Enter" || event.shiftKey) return;
|
||
event.preventDefault();
|
||
void handleSendMessage();
|
||
},
|
||
[
|
||
mentionPopupVisible,
|
||
filteredMentionAgents,
|
||
mentionHighlightIndex,
|
||
handleMentionSelect,
|
||
handleSendMessage,
|
||
fileMention,
|
||
messageInput,
|
||
showSkillMenu,
|
||
filteredSkills,
|
||
highlightedSkillIndex,
|
||
handleSkillSelect,
|
||
],
|
||
);
|
||
|
||
// Handle FAB click - only toggle if this was a click (not a drag)
|
||
// Reset didDragRef after checking to prevent double-toggle
|
||
const handleFABClick = useCallback(() => {
|
||
if (didDragRef.current) {
|
||
// Was a drag, don't toggle
|
||
didDragRef.current = false;
|
||
return;
|
||
}
|
||
if (isOpen) {
|
||
setIsOpen(false);
|
||
return;
|
||
}
|
||
// iOS only opens the soft keyboard from a focus() that runs while
|
||
// the originating user-gesture is still active, AND the focused
|
||
// element must not be `disabled`. The real composer input renders
|
||
// disabled until the chat session is created, so we focus an
|
||
// always-mounted stealth input here to claim the keyboard now; the
|
||
// auto-focus effect below transfers focus to the real input once
|
||
// it is enabled, which keeps the keyboard up.
|
||
if (typeof window !== "undefined" && window.innerWidth <= QUICK_CHAT_DESKTOP_BREAKPOINT) {
|
||
stealthInputRef.current?.focus({ preventScroll: true });
|
||
}
|
||
setIsOpen(true);
|
||
}, [isOpen, setIsOpen]);
|
||
|
||
return (
|
||
<>
|
||
<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"
|
||
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>
|
||
{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">
|
||
<label htmlFor="quick-chat-session-select" className="visually-hidden">Select session</label>
|
||
<select
|
||
id="quick-chat-session-select"
|
||
value={activeSession?.id ?? ""}
|
||
onChange={(event) => handleSessionSwitch(event.target.value)}
|
||
data-testid="quick-chat-session-dropdown"
|
||
>
|
||
<option value="" disabled>{sessionsLoading ? "Loading sessions…" : "Select a session"}</option>
|
||
{sessionOptions.map((sessionOption) => (
|
||
<option key={sessionOption.id} value={sessionOption.id}>{sessionOption.label}</option>
|
||
))}
|
||
</select>
|
||
</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>
|
||
) : isStreaming ? (
|
||
<>
|
||
{messages.map((message: ChatMessageInfo) => (
|
||
<QuickChatMessageItem
|
||
key={message.id}
|
||
message={message}
|
||
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
|
||
mentionAgentsByName={mentionAgentsByName}
|
||
onToggleRender={toggleMessageRenderMode}
|
||
/>
|
||
))}
|
||
{helpMessageVisible && (
|
||
<div className="quick-chat-panel-message quick-chat-panel-message--received" data-testid="quick-chat-help-message">
|
||
{renderAssistantMessageContent("Available commands:\n- `/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">{streamingThinking}</pre>
|
||
</details>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : messagesLoading ? (
|
||
<div className="quick-chat-panel-empty">Loading conversation…</div>
|
||
) : messages.length === 0 && !streamingText && !streamingThinking && !isStreaming && !helpMessageVisible ? (
|
||
<div className="quick-chat-panel-empty">No messages yet. Start the conversation!</div>
|
||
) : (
|
||
<>
|
||
{messages.map((message: ChatMessageInfo) => (
|
||
<QuickChatMessageItem
|
||
key={message.id}
|
||
message={message}
|
||
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
|
||
mentionAgentsByName={mentionAgentsByName}
|
||
onToggleRender={toggleMessageRenderMode}
|
||
/>
|
||
))}
|
||
{helpMessageVisible && (
|
||
<div className="quick-chat-panel-message quick-chat-panel-message--received" data-testid="quick-chat-help-message">
|
||
{renderAssistantMessageContent("Available commands:\n- `/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>
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
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;
|
||
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={inputDisabled || (messageInput.trim().length === 0 && pendingAttachments.length === 0)}
|
||
data-testid="quick-chat-send"
|
||
>
|
||
<Send size={16} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<AgentMentionPopup
|
||
agents={agents}
|
||
filter={mentionFilter}
|
||
highlightedIndex={mentionHighlightIndex}
|
||
visible={mentionPopupVisible}
|
||
onSelect={handleMentionSelect}
|
||
position="above"
|
||
/>
|
||
<FileMentionPopup
|
||
visible={fileMention.mentionActive && !mentionPopupVisible}
|
||
position={fileMentionPosition}
|
||
files={fileMention.files}
|
||
selectedIndex={fileMention.selectedIndex}
|
||
onSelect={(file) => {
|
||
const newText = fileMention.selectFile(file, messageInput);
|
||
setMessageInput(newText);
|
||
fileMention.dismissMention();
|
||
setFileMentionPopupVisible(false);
|
||
inputRef.current?.focus();
|
||
}}
|
||
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>
|
||
)}
|
||
{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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|