ChatView and QuickChatFAB both had an iOS-specific onTouchStart on the textarea that called event.preventDefault() and then programmatically re-focused the input — meant to suppress iOS's visualViewport auto-scroll on re-focus. On Android, preventDefault on a textarea touchstart blocks the soft keyboard from opening (programmatic focus() alone does not raise the Android keyboard — only the default touch action does), so tapping the main chat or quick chat composer focused the input but the keyboard never appeared, looking like an instant dismiss. Gate the touchstart workaround to iOS via isIOS(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3448 lines
127 KiB
TypeScript
3448 lines
127 KiB
TypeScript
// ChatView.css is imported eagerly from App.tsx to avoid a flash of
|
||
// unstyled content when the lazy chunk loads. Do not re-import here.
|
||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||
import ReactMarkdown from "react-markdown";
|
||
import remarkGfm from "remark-gfm";
|
||
import type { Components } from "react-markdown";
|
||
import {
|
||
MessageSquare,
|
||
Send,
|
||
Plus,
|
||
Search,
|
||
Trash2,
|
||
Archive,
|
||
ChevronLeft,
|
||
Bot,
|
||
Square,
|
||
Eye,
|
||
EyeOff,
|
||
Paperclip,
|
||
File,
|
||
Wrench,
|
||
ChevronDown,
|
||
Copy,
|
||
Check,
|
||
TriangleAlert,
|
||
ArrowUpToLine,
|
||
} from "lucide-react";
|
||
import { useChat, type ChatMessageInfo, type FailureInfo, type ToolCallInfo } from "../hooks/useChat";
|
||
import { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../hooks/useChatRooms";
|
||
import { useChatUnread } from "../hooks/useChatUnread";
|
||
import { useViewportMode } from "./Header";
|
||
import { updateGlobalSettings, type DiscoveredSkill } from "../api";
|
||
import type { Agent } from "@fusion/core";
|
||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||
import { ProviderIcon } from "./ProviderIcon";
|
||
import { AgentMentionPopup } from "./AgentMentionPopup";
|
||
import { AgentAvatar } from "./AgentAvatar";
|
||
import { FileMentionPopup } from "./FileMentionPopup";
|
||
import { CreateRoomModal } from "./CreateRoomModal";
|
||
import { useFileMention } from "../hooks/useFileMention";
|
||
import { useModelsCache } from "../hooks/useModelsCache";
|
||
import { useDiscoveredSkillsCache } from "../hooks/useDiscoveredSkillsCache";
|
||
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
|
||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||
import { useMobileScrollLock, isIOS } from "../hooks/useMobileScrollLock";
|
||
import { matchesAgentMentionFilter } from "./mentionMatching";
|
||
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||
|
||
export interface ChatViewProps {
|
||
projectId?: string;
|
||
addToast: (msg: string, type?: "success" | "error" | "warning") => void;
|
||
experimentalFeatures?: Record<string, boolean>;
|
||
}
|
||
|
||
// Keep a generous cap so pasted multi-paragraph text stays visible while
|
||
// still preventing the composer from overtaking the message pane on short viewports.
|
||
const CHAT_INPUT_MAX_HEIGHT_PX = 640;
|
||
let chatViewWasPreviouslyInactive = false;
|
||
|
||
export function clampChatInputHeight(scrollHeight: number): number {
|
||
// Floor matches QuickChat (clampQuickChatInputHeight) and the CSS min-height,
|
||
// so a 0-scrollHeight measurement (e.g. before layout) still yields a
|
||
// sensible inline height instead of collapsing the composer to 0.
|
||
return Math.max(40, Math.min(scrollHeight, CHAT_INPUT_MAX_HEIGHT_PX));
|
||
}
|
||
|
||
function formatRelativeTime(dateStr: string): string {
|
||
const date = new Date(dateStr);
|
||
const now = new Date();
|
||
const diffMs = now.getTime() - date.getTime();
|
||
const diffSecs = Math.floor(diffMs / 1000);
|
||
const diffMins = Math.floor(diffSecs / 60);
|
||
const diffHours = Math.floor(diffMins / 60);
|
||
const diffDays = Math.floor(diffHours / 24);
|
||
|
||
if (diffSecs < 60) return "just now";
|
||
if (diffMins < 60) return `${diffMins}m ago`;
|
||
if (diffHours < 24) return `${diffHours}h ago`;
|
||
if (diffDays < 7) return `${diffDays}d ago`;
|
||
return date.toLocaleDateString();
|
||
}
|
||
|
||
/**
|
||
* Format a model provider and ID into a human-readable tag.
|
||
* Returns null if provider or modelId is missing/empty.
|
||
*/
|
||
function formatModelTag(provider?: string | null, modelId?: string | null): string | null {
|
||
if (!provider || !modelId) return null;
|
||
|
||
// Handle known provider/model patterns
|
||
const normalizedModel = modelId.toLowerCase();
|
||
|
||
// Claude models: "claude-sonnet-4-5" -> "Claude Sonnet 4.5"
|
||
if (normalizedModel.includes("claude")) {
|
||
let formatted = modelId
|
||
.replace(/^claude[- ]/i, "Claude ")
|
||
.replace(/sonnet[- ](\d+)[- ](\d+)/i, "Sonnet $1.$2")
|
||
.replace(/sonnet[- ](\d+)/i, "Sonnet $1")
|
||
.replace(/haiku[- ](\d+)/i, "Haiku $1")
|
||
.replace(/opus[- ](\d+)/i, "Opus $1")
|
||
.replace(/sonnet/i, "Sonnet")
|
||
.replace(/haiku/i, "Haiku")
|
||
.replace(/opus/i, "Opus")
|
||
.replace(/-/g, " ")
|
||
.trim();
|
||
// Fix double spaces
|
||
formatted = formatted.replace(/\s+/g, " ");
|
||
return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted;
|
||
}
|
||
|
||
// OpenAI models: "gpt-4o" -> "GPT-4o", "gpt-4-turbo" -> "GPT-4 Turbo"
|
||
if (normalizedModel.includes("gpt") || normalizedModel.includes("openai")) {
|
||
// Format GPT model names: handle special cases first, then capitalize
|
||
// Note: We don't replace hyphens globally because special cases preserve them
|
||
const formatted = modelId
|
||
.replace(/^gpt-4-turbo$/i, "GPT-4 Turbo")
|
||
.replace(/^gpt-4o-mini$/i, "GPT-4o Mini")
|
||
.replace(/^gpt-4o$/i, "GPT-4o")
|
||
.replace(/^gpt-4$/i, "GPT-4")
|
||
.replace(/^gpt-o1-preview$/i, "GPT-o1 Preview")
|
||
.replace(/^gpt-o1-mini$/i, "GPT-o1 Mini")
|
||
.replace(/^gpt-o1$/i, "GPT-o1")
|
||
.replace(/^gpt/i, "GPT") // Capitalize remaining GPT prefix
|
||
.trim();
|
||
return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted;
|
||
}
|
||
|
||
// Gemini models: "gemini-2.5-pro" -> "Gemini 2.5 Pro"
|
||
if (normalizedModel.includes("gemini")) {
|
||
const formatted = modelId
|
||
.replace(/^gemini[- ]/i, "Gemini ")
|
||
.replace(/pro[- ](\d+)[- ](\d+)/i, "Pro $1.$2")
|
||
.replace(/pro[- ](\d+)/i, "Pro $1")
|
||
.replace(/-/g, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted;
|
||
}
|
||
|
||
// Generic fallback: capitalize first letter, replace hyphens with spaces
|
||
const formatted = modelId
|
||
.replace(/-/g, " ")
|
||
.replace(/^\w/, (c) => c.toUpperCase())
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted;
|
||
}
|
||
|
||
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 buildFailureReferenceHref(reference: FailureInfo["reference"]): string | null {
|
||
if (!reference) {
|
||
return null;
|
||
}
|
||
|
||
if (reference.kind === "mailbox" || reference.kind === "mailbox-message") {
|
||
const pathname = typeof window === "undefined" ? "/" : window.location.pathname || "/";
|
||
const params = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search);
|
||
params.set("view", "mailbox");
|
||
params.set("mailbox-message", reference.id);
|
||
return `${pathname}?${params.toString()}#message-${encodeURIComponent(reference.id)}`;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function renderFailureReference(reference: FailureInfo["reference"]): ReactNode {
|
||
if (!reference) {
|
||
return null;
|
||
}
|
||
|
||
const referenceLabel = reference.label ?? `${reference.kind} ${reference.id}`;
|
||
const referenceHref = buildFailureReferenceHref(reference);
|
||
const referenceDetailsId = `chat-failure-reference-${reference.kind}-${reference.id}`
|
||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||
.toLowerCase();
|
||
|
||
return (
|
||
<div className="chat-message-failure-reference">
|
||
<span className="chat-message-failure-reference-label">Reference</span>
|
||
<span className="chat-message-failure-reference-value">{referenceLabel}</span>
|
||
{referenceHref ? (
|
||
<a className="btn btn-sm chat-message-failure-reference-link" href={referenceHref}>
|
||
Open mailbox message
|
||
</a>
|
||
) : (
|
||
<details className="chat-message-failure-reference-details">
|
||
<summary className="btn btn-sm chat-message-failure-reference-link">View failure details</summary>
|
||
<dl className="chat-message-failure-reference-meta" id={referenceDetailsId}>
|
||
<div>
|
||
<dt>Kind</dt>
|
||
<dd>{reference.kind}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>ID</dt>
|
||
<dd>{reference.id}</dd>
|
||
</div>
|
||
{reference.label && (
|
||
<div>
|
||
<dt>Label</dt>
|
||
<dd>{reference.label}</dd>
|
||
</div>
|
||
)}
|
||
</dl>
|
||
</details>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function renderToolCalls(toolCalls?: ToolCallInfo[]): 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 summaryPreview = isRunning
|
||
? argsSummary
|
||
: resultSummary
|
||
? `result: ${resultSummary}`
|
||
: argsSummary
|
||
? `args: ${argsSummary}`
|
||
: null;
|
||
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";
|
||
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" 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 chatMarkdownComponents: Components = {
|
||
p: ({ children, ...props }) => (
|
||
<p {...props}>{linkifyReactChildren(children)}</p>
|
||
),
|
||
li: ({ children, ...props }) => (
|
||
<li {...props}>{linkifyReactChildren(children)}</li>
|
||
),
|
||
pre: ({ children, ...props }) => (
|
||
<pre {...props} className="chat-markdown-pre">
|
||
{children}
|
||
</pre>
|
||
),
|
||
code: ({ children, ...props }) => {
|
||
const text = typeof children === "string" ? children : React.Children.toArray(children).join("");
|
||
const linkedChildren = linkifyFilePaths(text);
|
||
if (linkedChildren.length === 1 && typeof linkedChildren[0] === "string") {
|
||
return <code {...props}>{children}</code>;
|
||
}
|
||
return <code {...props}>{linkedChildren}</code>;
|
||
},
|
||
table: ({ children, ...props }) => (
|
||
<table {...props} className="chat-markdown-table">
|
||
{children}
|
||
</table>
|
||
),
|
||
};
|
||
|
||
/**
|
||
* Constant agent ID for the built-in fn agent.
|
||
* The chat system always uses createFnAgent with CHAT_SYSTEM_PROMPT regardless
|
||
* of the agentId stored on the session. This ID serves as metadata only.
|
||
*/
|
||
const FN_AGENT_ID = "__fn_agent__";
|
||
const CHAT_SIDEBAR_DEFAULT_WIDTH = 280;
|
||
const CHAT_SIDEBAR_MIN_WIDTH = 180;
|
||
const CHAT_SIDEBAR_MAX_WIDTH = 500;
|
||
const CHAT_SIDEBAR_STORAGE_KEY = "fusion:chat-sidebar-width";
|
||
const CHAT_SCOPE_STORAGE_KEY = "fusion:chat-scope";
|
||
const CHAT_DRAFT_STORAGE_PREFIX = "fusion:chat-draft:";
|
||
|
||
function getChatDraftKey(scope: "direct" | "rooms", id: string | null | undefined): string | null {
|
||
if (!id) {
|
||
return null;
|
||
}
|
||
|
||
return `${CHAT_DRAFT_STORAGE_PREFIX}${scope}:${id}`;
|
||
}
|
||
|
||
function getPersistedChatDraft(key: string | null): string {
|
||
if (!key) {
|
||
return "";
|
||
}
|
||
|
||
try {
|
||
return localStorage.getItem(key) ?? "";
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
interface PendingAttachment {
|
||
file: File;
|
||
previewUrl: string;
|
||
}
|
||
|
||
const ALLOWED_ATTACHMENT_TYPES = [
|
||
"image/png",
|
||
"image/jpeg",
|
||
"image/gif",
|
||
"image/webp",
|
||
"text/plain",
|
||
"application/json",
|
||
"text/yaml",
|
||
"text/markdown",
|
||
"text/csv",
|
||
"application/xml",
|
||
"text/x-log",
|
||
];
|
||
|
||
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\n])@([\w-]*)$/.exec(textBeforeCursor);
|
||
if (!triggerMatch) {
|
||
return null;
|
||
}
|
||
|
||
const filter = triggerMatch[2] ?? "";
|
||
const start = textBeforeCursor.length - filter.length - 1;
|
||
return {
|
||
filter,
|
||
start,
|
||
end: cursorPos,
|
||
};
|
||
}
|
||
|
||
type DefaultModelSelection = {
|
||
provider: string | null;
|
||
modelId: string | null;
|
||
};
|
||
|
||
type SessionModelSelection = {
|
||
modelProvider?: string | null;
|
||
modelId?: string | null;
|
||
};
|
||
|
||
function getRuntimeConfigModelSelection(agent?: Agent): { provider: string; modelId: string } | null {
|
||
const runtimeConfig = agent?.runtimeConfig;
|
||
if (!runtimeConfig || typeof runtimeConfig !== "object") {
|
||
return null;
|
||
}
|
||
|
||
const modelProvider = Reflect.get(runtimeConfig, "modelProvider");
|
||
const modelId = Reflect.get(runtimeConfig, "modelId");
|
||
if (typeof modelProvider !== "string" || modelProvider.trim().length === 0) {
|
||
return null;
|
||
}
|
||
if (typeof modelId !== "string" || modelId.trim().length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
provider: modelProvider,
|
||
modelId,
|
||
};
|
||
}
|
||
|
||
export function resolveSessionProvider(
|
||
session: SessionModelSelection | null | undefined,
|
||
agent: Agent | null | undefined,
|
||
defaults: DefaultModelSelection,
|
||
): { provider: string; modelId: string } | null {
|
||
if (session?.modelProvider && session?.modelId) {
|
||
return {
|
||
provider: session.modelProvider,
|
||
modelId: session.modelId,
|
||
};
|
||
}
|
||
|
||
const runtimeSelection = getRuntimeConfigModelSelection(agent ?? undefined);
|
||
if (runtimeSelection) {
|
||
return runtimeSelection;
|
||
}
|
||
|
||
if (defaults.provider && defaults.modelId) {
|
||
return {
|
||
provider: defaults.provider,
|
||
modelId: defaults.modelId,
|
||
};
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
interface NewChatDialogProps {
|
||
projectId?: string;
|
||
onClose: () => void;
|
||
onCreate: (input: { agentId: string; modelProvider?: string; modelId?: string }) => void;
|
||
}
|
||
|
||
function NewChatDialog({ projectId, onClose, onCreate }: NewChatDialogProps) {
|
||
const [chatMode, setChatMode] = useState<"agent" | "model">("agent");
|
||
const { agents, loading: agentsLoading } = useAgentsMapCache(projectId);
|
||
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
|
||
const { models, favoriteProviders: cachedFavoriteProviders, favoriteModels: cachedFavoriteModels, loading: modelsLoading, refresh } = useModelsCache();
|
||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>(cachedFavoriteProviders);
|
||
const [favoriteModels, setFavoriteModels] = useState<string[]>(cachedFavoriteModels);
|
||
|
||
useEffect(() => {
|
||
setFavoriteProviders(cachedFavoriteProviders);
|
||
}, [cachedFavoriteProviders]);
|
||
|
||
useEffect(() => {
|
||
setFavoriteModels(cachedFavoriteModels);
|
||
}, [cachedFavoriteModels]);
|
||
|
||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||
const currentFavorites = favoriteProviders;
|
||
const isFavorite = currentFavorites.includes(provider);
|
||
const newFavorites = isFavorite
|
||
? currentFavorites.filter((value) => value !== provider)
|
||
: [provider, ...currentFavorites];
|
||
|
||
setFavoriteProviders(newFavorites);
|
||
|
||
try {
|
||
await updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels });
|
||
await refresh();
|
||
} catch {
|
||
setFavoriteProviders(currentFavorites);
|
||
}
|
||
}, [favoriteProviders, favoriteModels, refresh]);
|
||
|
||
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
|
||
const currentFavorites = favoriteModels;
|
||
const isFavorite = currentFavorites.includes(modelId);
|
||
const newFavorites = isFavorite
|
||
? currentFavorites.filter((value) => value !== modelId)
|
||
: [modelId, ...currentFavorites];
|
||
|
||
setFavoriteModels(newFavorites);
|
||
|
||
try {
|
||
await updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites });
|
||
await refresh();
|
||
} catch {
|
||
setFavoriteModels(currentFavorites);
|
||
}
|
||
}, [favoriteModels, favoriteProviders, refresh]);
|
||
|
||
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||
e.preventDefault();
|
||
|
||
if (chatMode === "agent") {
|
||
if (!selectedAgentId) return;
|
||
onCreate({ agentId: selectedAgentId });
|
||
return;
|
||
}
|
||
|
||
// model mode
|
||
if (!selectedModel) return;
|
||
const slashIdx = selectedModel.indexOf("/");
|
||
if (slashIdx <= 0) return;
|
||
const modelProvider = selectedModel.slice(0, slashIdx);
|
||
const modelId = selectedModel.slice(slashIdx + 1);
|
||
onCreate({ agentId: FN_AGENT_ID, modelProvider, modelId });
|
||
};
|
||
|
||
const isSubmitDisabled =
|
||
chatMode === "agent" ? !selectedAgentId : !selectedModel;
|
||
|
||
return (
|
||
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={onClose} role="dialog" aria-modal="true">
|
||
<div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}>
|
||
<h3>New Chat</h3>
|
||
<div className="chat-new-dialog-mode-toggle" data-testid="chat-new-dialog-mode-toggle">
|
||
<button
|
||
type="button"
|
||
className={`chat-new-dialog-mode-btn${chatMode === "agent" ? " chat-new-dialog-mode-btn--active" : ""}`}
|
||
data-testid="chat-new-dialog-mode-agent"
|
||
onClick={() => {
|
||
setChatMode("agent");
|
||
setSelectedModel("");
|
||
}}
|
||
>
|
||
Agent
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`chat-new-dialog-mode-btn${chatMode === "model" ? " chat-new-dialog-mode-btn--active" : ""}`}
|
||
data-testid="chat-new-dialog-mode-model"
|
||
onClick={() => {
|
||
setChatMode("model");
|
||
setSelectedAgentId("");
|
||
}}
|
||
>
|
||
Model
|
||
</button>
|
||
</div>
|
||
<form onSubmit={handleSubmit}>
|
||
{chatMode === "agent" && (
|
||
<label className="chat-new-dialog-model-label">
|
||
Agent
|
||
{agentsLoading ? (
|
||
<div className="chat-new-dialog-loading">Loading agents...</div>
|
||
) : agents.length === 0 ? (
|
||
<div className="chat-new-dialog-empty">No agents available</div>
|
||
) : (
|
||
<div className="chat-new-dialog-agent-list">
|
||
{agents.map((agent) => (
|
||
<button
|
||
key={agent.id}
|
||
type="button"
|
||
className={`chat-new-dialog-agent-item${selectedAgentId === agent.id ? " chat-new-dialog-agent-item--selected" : ""}`}
|
||
onClick={() => setSelectedAgentId(agent.id)}
|
||
data-testid={`agent-option-${agent.id}`}
|
||
>
|
||
<Bot size={16} />
|
||
<span className="chat-new-dialog-agent-name">{agent.name}</span>
|
||
<span className="chat-new-dialog-agent-role">{agent.role}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</label>
|
||
)}
|
||
{chatMode === "model" && (
|
||
<div className="chat-new-dialog-model-dropdown" data-testid="chat-new-dialog-model-section">
|
||
{modelsLoading ? (
|
||
<div className="chat-new-dialog-loading">Loading models...</div>
|
||
) : (
|
||
<CustomModelDropdown
|
||
models={models}
|
||
value={selectedModel}
|
||
onChange={setSelectedModel}
|
||
label="Model"
|
||
placeholder="Select a model"
|
||
favoriteProviders={favoriteProviders}
|
||
onToggleFavorite={handleToggleFavorite}
|
||
favoriteModels={favoriteModels}
|
||
onToggleModelFavorite={handleToggleModelFavorite}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="chat-new-dialog-actions">
|
||
<button type="button" className="btn btn-sm" onClick={onClose}>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="btn btn-sm btn-primary"
|
||
disabled={isSubmitDisabled}
|
||
>
|
||
Create
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
|
||
type CopyFeedbackState = "success" | "error" | null;
|
||
|
||
interface RoomContext {
|
||
roomId: string;
|
||
roomName: string;
|
||
memberIds: ReadonlySet<string>;
|
||
}
|
||
|
||
interface ChatMessageItemProps {
|
||
message: ChatMessageInfo;
|
||
/**
|
||
* When true, render assistant message content as plain text instead of
|
||
* Markdown. The per-message eye toggle has been removed in favor of a
|
||
* single thread-level toggle in the chat header, so this is a global
|
||
* mirror of that header state.
|
||
*/
|
||
forcePlain: boolean;
|
||
agentName: string;
|
||
/**
|
||
* Hide the per-message agent identity (icon + name + model tag) on
|
||
* assistant bubbles. In model-only chats the agent identity *is* the
|
||
* active model and it's already shown in the thread header.
|
||
*/
|
||
hideAssistantIdentity: boolean;
|
||
showAssistantModelTag: boolean;
|
||
activeModelTag: string | null;
|
||
activeModelProvider: string | null;
|
||
activeSessionId: string | null;
|
||
mentionAgentsByName: Map<string, Agent>;
|
||
roomContext: RoomContext | null;
|
||
copyAction?: ReactNode;
|
||
onScrollToTop?: (messageId: string) => void;
|
||
}
|
||
|
||
// Renders a single chat message bubble. Memoized so the streaming bubble's
|
||
// per-frame state churn does not re-render every prior message (each one
|
||
// would re-run ReactMarkdown over its full content otherwise).
|
||
const ChatMessageItem = memo(function ChatMessageItem({
|
||
message,
|
||
forcePlain,
|
||
agentName,
|
||
hideAssistantIdentity,
|
||
showAssistantModelTag,
|
||
activeModelTag,
|
||
activeModelProvider,
|
||
activeSessionId,
|
||
mentionAgentsByName,
|
||
roomContext,
|
||
copyAction,
|
||
onScrollToTop,
|
||
}: ChatMessageItemProps) {
|
||
const isAssistantMessage = message.role === "assistant";
|
||
const failureInfo = isAssistantMessage ? message.failureInfo : undefined;
|
||
const showAssistantIdentity = isAssistantMessage && (!hideAssistantIdentity || Boolean(failureInfo));
|
||
|
||
const renderedUserContent = useMemo<ReactNode>(() => {
|
||
if (isAssistantMessage) return null;
|
||
const content = message.content;
|
||
const mentionRegex = /@([\w-]+)/g;
|
||
const parts: ReactNode[] = [];
|
||
let lastIndex = 0;
|
||
let match = mentionRegex.exec(content);
|
||
while (match) {
|
||
const [fullMatch, rawName = ""] = match;
|
||
const start = match.index;
|
||
if (start > lastIndex) parts.push(content.slice(lastIndex, start));
|
||
const normalizedName = rawName.replace(/_/g, " ").toLowerCase();
|
||
const mentionedAgent = mentionAgentsByName.get(normalizedName);
|
||
if (mentionedAgent) {
|
||
const isNonMember = Boolean(roomContext && !roomContext.memberIds.has(mentionedAgent.id));
|
||
const nonMemberLabel = isNonMember ? `Not a member of ${roomContext?.roomName}` : undefined;
|
||
parts.push(
|
||
<span
|
||
key={`${mentionedAgent.id}-${start}`}
|
||
className={`chat-mention-chip${isNonMember ? " chat-mention-chip--non-member" : ""}`}
|
||
title={nonMemberLabel}
|
||
aria-label={nonMemberLabel}
|
||
>
|
||
@{mentionedAgent.name.replace(/\s+/g, "_")}
|
||
</span>,
|
||
);
|
||
} else {
|
||
parts.push(fullMatch);
|
||
}
|
||
lastIndex = start + fullMatch.length;
|
||
match = mentionRegex.exec(content);
|
||
}
|
||
if (lastIndex < content.length) parts.push(content.slice(lastIndex));
|
||
return parts.length === 0 ? content : parts;
|
||
}, [isAssistantMessage, message.content, mentionAgentsByName, roomContext]);
|
||
|
||
const renderedAttachments = useMemo<ReactNode>(() => {
|
||
const attachments = message.attachments;
|
||
if (!attachments || attachments.length === 0) return null;
|
||
const attachmentUrlBase = message.roomId
|
||
? `/api/chat/rooms/${encodeURIComponent(message.roomId)}/attachments/`
|
||
: (activeSessionId ? `/api/chat/sessions/${encodeURIComponent(activeSessionId)}/attachments/` : null);
|
||
if (!attachmentUrlBase) return null;
|
||
return (
|
||
<div className="chat-message-attachments">
|
||
{attachments.map((attachment) => {
|
||
const isImage = attachment.mimeType.startsWith("image/");
|
||
const key = attachment.id || attachment.filename;
|
||
const href = `${attachmentUrlBase}${encodeURIComponent(attachment.filename)}`;
|
||
if (isImage) {
|
||
return (
|
||
<a
|
||
key={key}
|
||
className="chat-message-attachment-link"
|
||
data-testid="chat-message-attachment"
|
||
href={href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
<img
|
||
className="chat-message-attachment"
|
||
src={href}
|
||
alt={attachment.originalName}
|
||
/>
|
||
</a>
|
||
);
|
||
}
|
||
return (
|
||
<a
|
||
key={key}
|
||
className="chat-message-attachment-file"
|
||
data-testid="chat-message-attachment"
|
||
href={href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
<File size={14} />
|
||
<span>{attachment.originalName}</span>
|
||
</a>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}, [message.attachments, message.roomId, activeSessionId]);
|
||
const assistantBody = useMemo<ReactNode>(() => {
|
||
if (!isAssistantMessage) return null;
|
||
if (failureInfo) {
|
||
return (
|
||
<div className="chat-message-content chat-message-content--failure">
|
||
<div className="chat-message-failure-summary-row">
|
||
<span className="status-dot status-dot--error" aria-hidden="true" />
|
||
<span className="chat-message-failure-label">Response failed</span>
|
||
</div>
|
||
<div className="chat-message-failure-summary">{failureInfo.summary}</div>
|
||
{(failureInfo.errorClass || failureInfo.code) && (
|
||
<div className="chat-message-failure-badges">
|
||
{failureInfo.errorClass && <span className="chat-message-failure-badge">{failureInfo.errorClass}</span>}
|
||
{failureInfo.code && <span className="chat-message-failure-badge">{failureInfo.code}</span>}
|
||
</div>
|
||
)}
|
||
{(failureInfo.detail || failureInfo.reference) && (
|
||
<details className="chat-message-failure-details">
|
||
<summary>
|
||
<TriangleAlert size={14} aria-hidden="true" />
|
||
<span>Failure details</span>
|
||
</summary>
|
||
{failureInfo.detail && <pre className="chat-message-failure-detail">{linkifyFilePaths(failureInfo.detail)}</pre>}
|
||
{renderFailureReference(failureInfo.reference)}
|
||
</details>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
if (forcePlain) {
|
||
return <div className="chat-message-content chat-message-content--plain">{message.content}</div>;
|
||
}
|
||
return (
|
||
<div className="chat-message-content chat-message-content--markdown">
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={chatMarkdownComponents}>
|
||
{message.content}
|
||
</ReactMarkdown>
|
||
</div>
|
||
);
|
||
}, [failureInfo, forcePlain, isAssistantMessage, message.content]);
|
||
|
||
return (
|
||
<div
|
||
className={`chat-message chat-message--${message.role}${failureInfo ? " chat-message--failure" : ""}`}
|
||
data-testid={`chat-message-${message.id}`}
|
||
data-message-id={message.id}
|
||
>
|
||
{showAssistantIdentity && (
|
||
<div className="chat-message-avatar">
|
||
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}
|
||
<span>{agentName}</span>
|
||
{showAssistantModelTag && activeModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||
</div>
|
||
)}
|
||
{isAssistantMessage
|
||
? assistantBody
|
||
: <div className="chat-message-content">{renderedUserContent}</div>}
|
||
{isAssistantMessage && !failureInfo && (copyAction || onScrollToTop) && (
|
||
<div className="chat-message-actions">
|
||
{copyAction}
|
||
{onScrollToTop && (
|
||
<button
|
||
type="button"
|
||
className="btn-icon chat-message-scroll-to-top-action"
|
||
aria-label="Scroll message to top"
|
||
data-testid={`chat-message-scroll-to-top-${message.id}`}
|
||
onClick={() => onScrollToTop(message.id)}
|
||
>
|
||
<ArrowUpToLine size={14} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
{renderToolCalls(message.toolCalls)}
|
||
{message.thinkingOutput && (
|
||
<details className="chat-message-thinking">
|
||
<summary>Thinking</summary>
|
||
<pre className="chat-message-thinking-content">{linkifyFilePaths(message.thinkingOutput)}</pre>
|
||
</details>
|
||
)}
|
||
{renderedAttachments}
|
||
<div className="chat-message-time">{formatRelativeTime(message.createdAt)}</div>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
export function ChatView({ projectId, addToast, experimentalFeatures }: ChatViewProps) {
|
||
useEffect(() => {
|
||
recordResumeEvent({
|
||
view: "ChatView",
|
||
trigger: chatViewWasPreviouslyInactive ? "route-active" : "remount",
|
||
projectId,
|
||
replayAttempted: false,
|
||
});
|
||
chatViewWasPreviouslyInactive = false;
|
||
|
||
return () => {
|
||
chatViewWasPreviouslyInactive = true;
|
||
recordResumeEvent({
|
||
view: "ChatView",
|
||
trigger: "route-inactive",
|
||
projectId,
|
||
replayAttempted: false,
|
||
});
|
||
};
|
||
}, [projectId]);
|
||
|
||
const {
|
||
activeSession,
|
||
sessionsLoading,
|
||
messages,
|
||
messagesLoading,
|
||
isStreaming,
|
||
streamingText,
|
||
streamingThinking,
|
||
streamingToolCalls,
|
||
selectSession,
|
||
createSession,
|
||
archiveSession,
|
||
deleteSession,
|
||
sendMessage,
|
||
stopStreaming,
|
||
pendingMessage,
|
||
clearPendingMessage,
|
||
searchQuery,
|
||
setSearchQuery,
|
||
filteredSessions,
|
||
agentsMap: chatAgentsMap,
|
||
} = useChat(projectId, addToast);
|
||
|
||
const [showNewDialog, setShowNewDialog] = useState(false);
|
||
const chatRoomsEnabled = experimentalFeatures?.chatRooms === true;
|
||
const [chatScope, setChatScope] = useState<"direct" | "rooms">(() => {
|
||
try {
|
||
const persistedScope = localStorage.getItem(CHAT_SCOPE_STORAGE_KEY);
|
||
if (persistedScope === "rooms" && chatRoomsEnabled) {
|
||
return "rooms";
|
||
}
|
||
} catch {
|
||
// Ignore storage errors.
|
||
}
|
||
|
||
return "direct";
|
||
});
|
||
// Keep this hook unconditional to preserve hook ordering and test stability.
|
||
// Rooms UI and interactions are fully gated by `chatRoomsEnabled`.
|
||
const rooms = useChatRooms(projectId, addToast);
|
||
const { isUnread, markRead } = useChatUnread(projectId);
|
||
const [messageInput, setMessageInput] = useState(() => {
|
||
const initialDraftKey = getChatDraftKey(
|
||
chatScope,
|
||
chatScope === "rooms" ? rooms.activeRoom?.id : activeSession?.id,
|
||
);
|
||
return getPersistedChatDraft(initialDraftKey);
|
||
});
|
||
const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null);
|
||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||
const [confirmDeleteRoomId, setConfirmDeleteRoomId] = useState<string | null>(null);
|
||
const [sidebarVisible, setSidebarVisible] = useState(true);
|
||
const [sidebarWidth, setSidebarWidth] = useState(CHAT_SIDEBAR_DEFAULT_WIDTH);
|
||
const [createRoomOpen, setCreateRoomOpen] = useState(false);
|
||
const { agentsMap: cachedAgentsMap } = useAgentsMapCache(projectId);
|
||
const agentsMap = useMemo(() => (chatAgentsMap.size > 0 ? chatAgentsMap : cachedAgentsMap), [cachedAgentsMap, chatAgentsMap]);
|
||
const { defaultProvider, defaultModelId } = useModelsCache();
|
||
const defaultModel = useMemo<DefaultModelSelection>(() => ({ provider: defaultProvider, modelId: defaultModelId }), [defaultModelId, defaultProvider]);
|
||
const { skills: discoveredSkills, loading: skillsLoading } = useDiscoveredSkillsCache(projectId);
|
||
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);
|
||
// Single thread-wide toggle: when true, all assistant content (including the
|
||
// streaming bubble) renders as plain text instead of Markdown. Replaces the
|
||
// earlier per-message toggle so the chat header owns this control instead
|
||
// of every reply having its own button.
|
||
const [showAllAsPlain, setShowAllAsPlain] = useState(false);
|
||
// Attachment state mirrors QuickEntryBox: pending files selected before send.
|
||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||
const [isDragOver, setIsDragOver] = useState(false);
|
||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||
const [copyFeedbackByMessageId, setCopyFeedbackByMessageId] = useState<Record<string, CopyFeedbackState>>({});
|
||
const [mobileSessionMenuOpen, setMobileSessionMenuOpen] = useState(false);
|
||
const [roomSwitcherOpen, setRoomSwitcherOpen] = useState(false);
|
||
const { pushNav } = useNavigationHistoryContext();
|
||
|
||
// 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 textarea
|
||
const updateFileMentionPosition = useCallback((textarea: HTMLTextAreaElement | null) => {
|
||
if (!textarea || !fileMention.mentionActive) return;
|
||
|
||
// Get textarea position
|
||
const rect = textarea.getBoundingClientRect();
|
||
|
||
// Position above the textarea, 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]);
|
||
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
const mobileSessionMenuRef = useRef<HTMLDivElement>(null);
|
||
const roomSwitcherRef = useRef<HTMLDivElement>(null);
|
||
const isUserScrollingRef = useRef(false);
|
||
const lastAnchoredThreadStateRef = useRef<{ threadId: string; loaded: boolean; hasMessages: boolean } | null>(null);
|
||
const previousChatScopeRef = useRef<"direct" | "rooms" | null>(null);
|
||
const directThreadDeferredAnchorTimeoutRef = useRef<number | null>(null);
|
||
const lastMessageCountRef = useRef(0);
|
||
const lastThreadIdRef = useRef<string | null>(null);
|
||
const scrollRestoreSnapshotRef = useRef<{
|
||
threadId: string;
|
||
scrollTop: number;
|
||
scrollHeight: number;
|
||
clientHeight: number;
|
||
anchorMessageId: string | null;
|
||
anchorOffset: number;
|
||
wasPinnedBefore: boolean;
|
||
} | null>(null);
|
||
const hideSkillMenuTimeoutRef = useRef<number | null>(null);
|
||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||
const chatThreadRef = useRef<HTMLDivElement | null>(null);
|
||
// FN-5365: mirror QuickChat's mid-dismiss suppress gate so transient
|
||
// visualViewport shrink samples do not jerk the chat thread/composer.
|
||
const suppressVvShrinkRef = useRef(false);
|
||
const suppressVvShrinkTimeoutRef = useRef<number | null>(null);
|
||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
|
||
const mentionCursorPosRef = useRef(0);
|
||
const copyFeedbackTimeoutsRef = useRef<Map<string, number>>(new Map());
|
||
const mode = useViewportMode();
|
||
const isMobile = mode === "mobile";
|
||
|
||
useEffect(() => {
|
||
if (!activeSession?.id) {
|
||
return;
|
||
}
|
||
|
||
markRead("direct", activeSession.id, activeSession.lastMessageAt ?? activeSession.updatedAt);
|
||
}, [activeSession?.id, activeSession?.lastMessageAt, activeSession?.updatedAt, markRead]);
|
||
|
||
useEffect(() => {
|
||
if (!rooms.activeRoom?.id) {
|
||
return;
|
||
}
|
||
|
||
markRead("room", rooms.activeRoom.id, rooms.activeRoom.updatedAt);
|
||
}, [rooms.activeRoom?.id, rooms.activeRoom?.updatedAt, markRead]);
|
||
|
||
useEffect(() => {
|
||
if (!activeSession?.id || messages.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const latestMessage = messages[messages.length - 1];
|
||
markRead("direct", activeSession.id, latestMessage?.createdAt ?? activeSession.lastMessageAt ?? activeSession.updatedAt);
|
||
}, [activeSession?.id, activeSession?.lastMessageAt, activeSession?.updatedAt, markRead, messages]);
|
||
|
||
useEffect(() => {
|
||
if (!rooms.activeRoom?.id || rooms.messages.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const latestMessage = rooms.messages[rooms.messages.length - 1];
|
||
markRead("room", rooms.activeRoom.id, latestMessage?.createdAt ?? rooms.activeRoom.updatedAt);
|
||
}, [markRead, rooms.activeRoom?.id, rooms.activeRoom?.updatedAt, rooms.messages]);
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const rawWidth = localStorage.getItem(CHAT_SIDEBAR_STORAGE_KEY);
|
||
if (!rawWidth) return;
|
||
const parsedWidth = Number.parseInt(rawWidth, 10);
|
||
if (Number.isNaN(parsedWidth)) return;
|
||
const clampedWidth = Math.max(CHAT_SIDEBAR_MIN_WIDTH, Math.min(CHAT_SIDEBAR_MAX_WIDTH, parsedWidth));
|
||
setSidebarWidth(clampedWidth);
|
||
} catch {
|
||
// Ignore storage errors.
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const persistedScope = localStorage.getItem(CHAT_SCOPE_STORAGE_KEY);
|
||
if (persistedScope === "direct") {
|
||
setChatScope("direct");
|
||
return;
|
||
}
|
||
if (persistedScope === "rooms" && chatRoomsEnabled) {
|
||
setChatScope("rooms");
|
||
}
|
||
} catch {
|
||
// Ignore storage errors.
|
||
}
|
||
}, [chatRoomsEnabled]);
|
||
|
||
useEffect(() => {
|
||
if (!chatRoomsEnabled && chatScope === "rooms") {
|
||
setChatScope("direct");
|
||
return;
|
||
}
|
||
try {
|
||
localStorage.setItem(CHAT_SCOPE_STORAGE_KEY, chatScope);
|
||
} catch {
|
||
// Ignore storage errors.
|
||
}
|
||
}, [chatRoomsEnabled, chatScope]);
|
||
|
||
const activeDraftKey = getChatDraftKey(
|
||
chatScope,
|
||
chatScope === "rooms" ? rooms.activeRoom?.id : activeSession?.id,
|
||
);
|
||
const lastDraftKeyRef = useRef<string | null>(activeDraftKey);
|
||
|
||
useEffect(() => {
|
||
if (activeDraftKey === lastDraftKeyRef.current) {
|
||
return;
|
||
}
|
||
|
||
lastDraftKeyRef.current = activeDraftKey;
|
||
setMessageInput(getPersistedChatDraft(activeDraftKey));
|
||
}, [activeDraftKey]);
|
||
|
||
useEffect(() => {
|
||
if (!activeDraftKey || lastDraftKeyRef.current !== activeDraftKey) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (messageInput) {
|
||
localStorage.setItem(activeDraftKey, messageInput);
|
||
return;
|
||
}
|
||
localStorage.removeItem(activeDraftKey);
|
||
} catch {
|
||
// Ignore storage errors.
|
||
}
|
||
}, [activeDraftKey, messageInput]);
|
||
|
||
const roomThreadActive = chatRoomsEnabled && chatScope === "rooms" && !!rooms.activeRoom;
|
||
const { keyboardOverlap, keyboardOpen } = useMobileKeyboard({
|
||
enabled: isMobile && (!!activeSession || roomThreadActive),
|
||
});
|
||
|
||
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 mentionAgents = useMemo(() => Array.from(agentsMap.values()), [agentsMap]);
|
||
|
||
const roomContext = useMemo<RoomContext | null>(() => {
|
||
if (!chatRoomsEnabled || chatScope !== "rooms" || !rooms.activeRoom) {
|
||
return null;
|
||
}
|
||
return {
|
||
roomId: rooms.activeRoom.id,
|
||
roomName: rooms.activeRoom.name,
|
||
memberIds: new Set(rooms.activeRoomMembers.map((member) => member.agentId)),
|
||
};
|
||
}, [chatRoomsEnabled, chatScope, rooms.activeRoom, rooms.activeRoomMembers]);
|
||
|
||
const filteredMentionAgents = useMemo(() => {
|
||
const matchingAgents = mentionAgents.filter((agent) => matchesAgentMentionFilter(agent.name, mentionFilter));
|
||
if (!roomContext) {
|
||
return matchingAgents;
|
||
}
|
||
|
||
const memberAgents = matchingAgents.filter((agent) => roomContext.memberIds.has(agent.id));
|
||
if (mentionFilter.trim().length === 0) {
|
||
return memberAgents;
|
||
}
|
||
|
||
const otherAgents = matchingAgents.filter((agent) => !roomContext.memberIds.has(agent.id));
|
||
return [...memberAgents, ...otherAgents];
|
||
}, [mentionAgents, mentionFilter, roomContext]);
|
||
|
||
const mentionAgentsByName = useMemo(() => {
|
||
const byName = new Map<string, Agent>();
|
||
for (const agent of mentionAgents) {
|
||
byName.set(agent.name.toLowerCase(), agent);
|
||
}
|
||
return byName;
|
||
}, [mentionAgents]);
|
||
|
||
useEffect(() => {
|
||
setHighlightedSkillIndex(0);
|
||
}, [filteredSkills]);
|
||
|
||
useEffect(() => {
|
||
setMentionHighlightIndex(0);
|
||
}, [mentionFilter, mentionPopupVisible]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (hideSkillMenuTimeoutRef.current !== null) {
|
||
window.clearTimeout(hideSkillMenuTimeoutRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const getActiveThreadId = useCallback(() => {
|
||
return roomThreadActive ? (rooms.activeRoom?.id ?? null) : (activeSession?.id ?? null);
|
||
}, [roomThreadActive, rooms.activeRoom?.id, activeSession?.id]);
|
||
|
||
const getMessageElement = useCallback((container: HTMLElement, messageId: string) => {
|
||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||
return container.querySelector<HTMLElement>(`.chat-message[data-message-id="${CSS.escape(messageId)}"]`);
|
||
}
|
||
return container.querySelector<HTMLElement>(`.chat-message[data-message-id="${messageId.replace(/"/g, "\\\"")}"]`);
|
||
}, []);
|
||
|
||
const captureScrollSnapshot = useCallback(() => {
|
||
const messagesContainer = messagesContainerRef.current;
|
||
const threadId = getActiveThreadId();
|
||
if (!messagesContainer || !threadId) return;
|
||
|
||
const anchorMessage = messagesContainer.querySelector<HTMLElement>(".chat-message[data-message-id]");
|
||
const anchorMessageId = anchorMessage?.getAttribute("data-message-id") ?? null;
|
||
const anchorOffset = anchorMessage ? anchorMessage.offsetTop - messagesContainer.scrollTop : 0;
|
||
|
||
scrollRestoreSnapshotRef.current = {
|
||
threadId,
|
||
scrollTop: messagesContainer.scrollTop,
|
||
scrollHeight: messagesContainer.scrollHeight,
|
||
clientHeight: messagesContainer.clientHeight,
|
||
anchorMessageId,
|
||
anchorOffset,
|
||
wasPinnedBefore: !isUserScrollingRef.current,
|
||
};
|
||
}, [getActiveThreadId]);
|
||
|
||
const updateScrollState = useCallback(() => {
|
||
const messagesContainer = messagesContainerRef.current;
|
||
if (!messagesContainer) return;
|
||
|
||
const threshold = 50;
|
||
const atBottom = messagesContainer.scrollTop + messagesContainer.clientHeight >= messagesContainer.scrollHeight - threshold;
|
||
setIsUserScrolling(!atBottom);
|
||
isUserScrollingRef.current = !atBottom;
|
||
captureScrollSnapshot();
|
||
}, [captureScrollSnapshot]);
|
||
|
||
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 activeThreadMessages = roomThreadActive ? rooms.messages : messages;
|
||
|
||
useLayoutEffect(() => {
|
||
const messagesContainer = messagesContainerRef.current;
|
||
const threadId = getActiveThreadId();
|
||
const snapshot = scrollRestoreSnapshotRef.current;
|
||
if (!messagesContainer || !threadId || !snapshot || snapshot.threadId !== threadId || snapshot.wasPinnedBefore) {
|
||
return;
|
||
}
|
||
|
||
let restoredScrollTop = snapshot.scrollTop;
|
||
if (snapshot.anchorMessageId) {
|
||
const anchorElement = getMessageElement(messagesContainer, snapshot.anchorMessageId);
|
||
if (anchorElement) {
|
||
restoredScrollTop = anchorElement.offsetTop - snapshot.anchorOffset;
|
||
} else {
|
||
restoredScrollTop = snapshot.scrollTop + (messagesContainer.scrollHeight - snapshot.scrollHeight);
|
||
}
|
||
} else {
|
||
restoredScrollTop = snapshot.scrollTop + (messagesContainer.scrollHeight - snapshot.scrollHeight);
|
||
}
|
||
|
||
messagesContainer.scrollTop = Math.max(0, restoredScrollTop);
|
||
isUserScrollingRef.current = true;
|
||
setIsUserScrolling(true);
|
||
scrollRestoreSnapshotRef.current = null;
|
||
}, [activeThreadMessages, getActiveThreadId, getMessageElement]);
|
||
|
||
const logScrollDebug = useCallback((cause: string) => {
|
||
if (typeof window === "undefined") {
|
||
return;
|
||
}
|
||
if (process.env.NODE_ENV === "production" || !(window as unknown as { FN_5380_DEBUG?: boolean }).FN_5380_DEBUG) {
|
||
return;
|
||
}
|
||
const container = messagesContainerRef.current;
|
||
const threshold = 50;
|
||
const atBottom = container
|
||
? container.scrollTop + container.clientHeight >= container.scrollHeight - threshold
|
||
: true;
|
||
console.debug("[chat-scroll]", {
|
||
cause,
|
||
wasPinnedBefore: !isUserScrollingRef.current,
|
||
atBottomNow: atBottom,
|
||
messageCount: activeThreadMessages.length,
|
||
roomThreadActive,
|
||
});
|
||
}, [activeThreadMessages.length, roomThreadActive]);
|
||
|
||
const scrollToBottom = useCallback((cause: string) => {
|
||
logScrollDebug(cause);
|
||
const messagesContainer = messagesContainerRef.current;
|
||
if (!messagesContainer) return;
|
||
anchorToBottom(messagesContainer);
|
||
}, [anchorToBottom, logScrollDebug]);
|
||
|
||
useLayoutEffect(() => {
|
||
if (directThreadDeferredAnchorTimeoutRef.current !== null) {
|
||
window.clearTimeout(directThreadDeferredAnchorTimeoutRef.current);
|
||
directThreadDeferredAnchorTimeoutRef.current = null;
|
||
}
|
||
|
||
const threadId = roomThreadActive ? (rooms.activeRoom?.id ?? null) : (activeSession?.id ?? null);
|
||
if (!threadId) {
|
||
lastAnchoredThreadStateRef.current = null;
|
||
return;
|
||
}
|
||
|
||
const nextState = {
|
||
threadId,
|
||
loaded: roomThreadActive ? !rooms.messagesLoading : !messagesLoading,
|
||
hasMessages: roomThreadActive ? rooms.messages.length > 0 : messages.length > 0,
|
||
};
|
||
const previousState = lastAnchoredThreadStateRef.current;
|
||
const isThreadChanged = previousState?.threadId !== threadId;
|
||
const finishedLoading = previousState?.threadId === threadId && !previousState.loaded && nextState.loaded;
|
||
const firstMessagesArrived =
|
||
previousState?.threadId === threadId && !previousState.hasMessages && nextState.hasMessages;
|
||
|
||
const shouldAnchor = previousState === null || isThreadChanged || finishedLoading || firstMessagesArrived;
|
||
if (!shouldAnchor) {
|
||
return;
|
||
}
|
||
|
||
const messagesContainer = messagesContainerRef.current;
|
||
if (!messagesContainer) {
|
||
return;
|
||
}
|
||
|
||
logScrollDebug(isThreadChanged ? "thread-change" : finishedLoading ? "finished-loading" : firstMessagesArrived ? "first-messages" : "mount");
|
||
anchorToBottom(messagesContainer);
|
||
if (!roomThreadActive) {
|
||
directThreadDeferredAnchorTimeoutRef.current = window.setTimeout(() => {
|
||
directThreadDeferredAnchorTimeoutRef.current = null;
|
||
if (isUserScrollingRef.current) {
|
||
return;
|
||
}
|
||
const latestContainer = messagesContainerRef.current;
|
||
if (!latestContainer) {
|
||
return;
|
||
}
|
||
anchorToBottom(latestContainer);
|
||
}, 250);
|
||
}
|
||
lastAnchoredThreadStateRef.current = nextState;
|
||
|
||
return () => {
|
||
if (directThreadDeferredAnchorTimeoutRef.current !== null) {
|
||
window.clearTimeout(directThreadDeferredAnchorTimeoutRef.current);
|
||
directThreadDeferredAnchorTimeoutRef.current = null;
|
||
}
|
||
};
|
||
}, [
|
||
roomThreadActive,
|
||
rooms.activeRoom?.id,
|
||
rooms.messages.length,
|
||
rooms.messagesLoading,
|
||
activeSession?.id,
|
||
messages.length,
|
||
messagesLoading,
|
||
anchorToBottom,
|
||
]);
|
||
|
||
// Scroll thread container to bottom during streaming only when already pinned.
|
||
useEffect(() => {
|
||
if (!isStreaming || isUserScrollingRef.current) {
|
||
return;
|
||
}
|
||
scrollToBottom("streaming");
|
||
}, [isStreaming, streamingText, streamingThinking, scrollToBottom]);
|
||
|
||
// Snap to latest on new messages only when the user was pinned before growth.
|
||
useEffect(() => {
|
||
const threadId = getActiveThreadId();
|
||
if (!threadId) {
|
||
lastMessageCountRef.current = 0;
|
||
lastThreadIdRef.current = null;
|
||
return;
|
||
}
|
||
|
||
if (lastThreadIdRef.current !== threadId) {
|
||
lastThreadIdRef.current = threadId;
|
||
lastMessageCountRef.current = activeThreadMessages.length;
|
||
return;
|
||
}
|
||
|
||
const previousCount = lastMessageCountRef.current;
|
||
const nextCount = activeThreadMessages.length;
|
||
const didGrow = nextCount > previousCount;
|
||
const wasPinnedBefore = !isUserScrollingRef.current;
|
||
|
||
lastMessageCountRef.current = nextCount;
|
||
|
||
if (didGrow && wasPinnedBefore) {
|
||
scrollToBottom("new-message");
|
||
}
|
||
}, [activeThreadMessages, getActiveThreadId, scrollToBottom]);
|
||
|
||
useEffect(() => {
|
||
if (keyboardOverlap <= 0) {
|
||
return;
|
||
}
|
||
|
||
const messagesContainer = messagesContainerRef.current;
|
||
if (!messagesContainer) {
|
||
return;
|
||
}
|
||
|
||
scrollToBottom("keyboard");
|
||
}, [keyboardOverlap, scrollToBottom]);
|
||
|
||
// Lock body scroll on mobile while the keyboard is up so iOS can't shift
|
||
// the visual viewport (offsetTop > 0). Shared hook also restores
|
||
// window.scrollTo(0, 0) on cleanup to recover from any iOS drift.
|
||
useMobileScrollLock(isMobile && keyboardOpen);
|
||
|
||
// FN-5365: mirror QuickChatFAB keyboard handling by writing visualViewport
|
||
// metrics directly to .chat-thread, avoiding React commit lag/jitter.
|
||
useLayoutEffect(() => {
|
||
if (!isMobile || (!activeSession && !roomThreadActive)) return;
|
||
if (typeof window === "undefined") return;
|
||
|
||
const thread = chatThreadRef.current;
|
||
const vv = window.visualViewport;
|
||
if (!thread || !vv) return;
|
||
|
||
const isKeyboardTrackingFocusable = (element: Element | null): boolean => {
|
||
if (!(element instanceof HTMLElement)) return false;
|
||
if (element.tagName === "TEXTAREA") return true;
|
||
if (element.tagName !== "INPUT") return false;
|
||
const inputType = (element as HTMLInputElement).type.toLowerCase();
|
||
return ["", "text", "search", "email", "url", "tel", "password", "number"].includes(inputType);
|
||
};
|
||
|
||
const apply = () => {
|
||
if (suppressVvShrinkRef.current) {
|
||
thread.classList.remove("chat-thread--keyboard-active");
|
||
return;
|
||
}
|
||
const overlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height);
|
||
const offsetTop = vv.offsetTop || 0;
|
||
thread.style.setProperty("--vv-height", `${vv.height}px`);
|
||
thread.style.setProperty("--vv-offset-top", `${offsetTop}px`);
|
||
thread.style.setProperty("--keyboard-overlap", `${overlap}px`);
|
||
|
||
const keyboardActive = (overlap > 0 || offsetTop > 0) && isKeyboardTrackingFocusable(document.activeElement);
|
||
thread.classList.toggle("chat-thread--keyboard-active", keyboardActive);
|
||
};
|
||
|
||
apply();
|
||
vv.addEventListener("resize", apply);
|
||
vv.addEventListener("scroll", apply);
|
||
document.addEventListener("focusin", apply);
|
||
document.addEventListener("focusout", apply);
|
||
window.addEventListener("pageshow", apply);
|
||
document.addEventListener("visibilitychange", apply);
|
||
return () => {
|
||
vv.removeEventListener("resize", apply);
|
||
vv.removeEventListener("scroll", apply);
|
||
document.removeEventListener("focusin", apply);
|
||
document.removeEventListener("focusout", apply);
|
||
window.removeEventListener("pageshow", apply);
|
||
document.removeEventListener("visibilitychange", apply);
|
||
thread.classList.remove("chat-thread--keyboard-active");
|
||
};
|
||
}, [activeSession, isMobile, roomThreadActive]);
|
||
|
||
// Close context menu on outside click
|
||
useEffect(() => {
|
||
const handleClick = () => setContextMenu(null);
|
||
if (contextMenu) {
|
||
document.addEventListener("click", handleClick);
|
||
return () => document.removeEventListener("click", handleClick);
|
||
}
|
||
}, [contextMenu]);
|
||
|
||
// While the keyboard is up on mobile, block touchmove gestures that
|
||
// would otherwise pan the iOS visualViewport (or scroll the document)
|
||
// and let the composer / header drift. We attach a non-passive listener
|
||
// to document so that gestures starting anywhere — header, composer
|
||
// padding, body — are cancelled. The exception is when the touch path
|
||
// crosses the messages list, which is the one place we DO want pan-y.
|
||
// useMobileScrollLock only pins document scroll; this complements it
|
||
// by stopping vv pan on top of the locked layout.
|
||
// React's synthetic onTouchMove is passive by default, so this has to
|
||
// be a native addEventListener with { passive: false }.
|
||
useEffect(() => {
|
||
if (!isMobile || !keyboardOpen) return;
|
||
const onTouchMove = (event: TouchEvent) => {
|
||
const target = event.target as Element | null;
|
||
if (target?.closest(".chat-messages")) return; // allow messages scroll
|
||
event.preventDefault();
|
||
};
|
||
document.addEventListener("touchmove", onTouchMove, { passive: false });
|
||
return () => {
|
||
document.removeEventListener("touchmove", onTouchMove);
|
||
};
|
||
}, [isMobile, keyboardOpen]);
|
||
|
||
// On mount and on visibility/page restore, if iOS thinks the keyboard is
|
||
// up but the textarea isn't actually focused (or vice versa), the
|
||
// visualViewport metrics get stuck in a half-state — composer pushed up
|
||
// or covered by a blank pane. Force a blur+refocus on the textarea to
|
||
// make iOS resync. Only runs on mobile and only when ChatView holds the
|
||
// active session (avoids stealing focus from other views).
|
||
useEffect(() => {
|
||
if (!isMobile || !activeSession) return;
|
||
const resync = () => {
|
||
const ta = inputRef.current;
|
||
if (!ta) return;
|
||
if (document.activeElement !== ta) return; // only if it was focused
|
||
ta.blur();
|
||
window.setTimeout(() => {
|
||
ta.focus({ preventScroll: true });
|
||
}, 0);
|
||
};
|
||
document.addEventListener("visibilitychange", resync);
|
||
window.addEventListener("pageshow", resync);
|
||
return () => {
|
||
document.removeEventListener("visibilitychange", resync);
|
||
window.removeEventListener("pageshow", resync);
|
||
};
|
||
}, [isMobile, activeSession]);
|
||
|
||
useEffect(() => {
|
||
const previousScope = previousChatScopeRef.current;
|
||
previousChatScopeRef.current = chatScope;
|
||
|
||
if (chatScope !== "direct") {
|
||
return;
|
||
}
|
||
|
||
if (previousScope !== null && previousScope !== "rooms") {
|
||
return;
|
||
}
|
||
|
||
const messagesContainer = messagesContainerRef.current;
|
||
if (!messagesContainer) {
|
||
return;
|
||
}
|
||
|
||
anchorToBottom(messagesContainer);
|
||
isUserScrollingRef.current = false;
|
||
setIsUserScrolling(false);
|
||
}, [chatScope, anchorToBottom]);
|
||
|
||
useEffect(() => {
|
||
if (!activeSession && !roomThreadActive) {
|
||
return;
|
||
}
|
||
if (roomThreadActive && !isMobile) {
|
||
return;
|
||
}
|
||
|
||
const captureForRefetch = () => {
|
||
captureScrollSnapshot();
|
||
};
|
||
|
||
const onVisibilityChange = () => {
|
||
if (document.visibilityState !== "visible") {
|
||
return;
|
||
}
|
||
captureForRefetch();
|
||
};
|
||
|
||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||
window.addEventListener("pageshow", captureForRefetch);
|
||
|
||
return () => {
|
||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||
window.removeEventListener("pageshow", captureForRefetch);
|
||
};
|
||
}, [isMobile, activeSession, roomThreadActive, captureScrollSnapshot]);
|
||
|
||
useEffect(() => {
|
||
if (roomThreadActive) {
|
||
return;
|
||
}
|
||
if (typeof ResizeObserver === "undefined") {
|
||
return;
|
||
}
|
||
|
||
const messagesContainer = messagesContainerRef.current;
|
||
if (!messagesContainer) {
|
||
return;
|
||
}
|
||
|
||
const observer = new ResizeObserver(() => {
|
||
if (isUserScrollingRef.current) {
|
||
return;
|
||
}
|
||
anchorToBottom(messagesContainer);
|
||
});
|
||
|
||
observer.observe(messagesContainer);
|
||
|
||
return () => {
|
||
observer.disconnect();
|
||
};
|
||
}, [roomThreadActive, anchorToBottom, activeSession?.id, chatScope]);
|
||
|
||
// Fetch agents on mount for name resolution (project-scoped with stale-request protection)
|
||
useEffect(() => {
|
||
pendingAttachmentsRef.current = pendingAttachments;
|
||
}, [pendingAttachments]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
for (const attachment of pendingAttachmentsRef.current) {
|
||
if (attachment.previewUrl) {
|
||
URL.revokeObjectURL(attachment.previewUrl);
|
||
}
|
||
}
|
||
for (const timeoutId of copyFeedbackTimeoutsRef.current.values()) {
|
||
window.clearTimeout(timeoutId);
|
||
}
|
||
copyFeedbackTimeoutsRef.current.clear();
|
||
};
|
||
}, []);
|
||
|
||
const handleAttachmentFiles = useCallback((files: FileList | File[] | null | undefined) => {
|
||
if (!files || files.length === 0) return;
|
||
|
||
const nextAttachments: PendingAttachment[] = [];
|
||
for (const file of Array.from(files)) {
|
||
if (!ALLOWED_ATTACHMENT_TYPES.includes(file.type)) {
|
||
continue;
|
||
}
|
||
const isImage = file.type.startsWith("image/");
|
||
nextAttachments.push({
|
||
file,
|
||
previewUrl: isImage ? URL.createObjectURL(file) : "",
|
||
});
|
||
}
|
||
|
||
if (nextAttachments.length > 0) {
|
||
setPendingAttachments((prev) => [...prev, ...nextAttachments]);
|
||
}
|
||
}, []);
|
||
|
||
const removeAttachment = useCallback((index: number) => {
|
||
setPendingAttachments((prev) => {
|
||
const attachment = prev[index];
|
||
if (attachment?.previewUrl) {
|
||
URL.revokeObjectURL(attachment.previewUrl);
|
||
}
|
||
return prev.filter((_, attachmentIndex) => attachmentIndex !== index);
|
||
});
|
||
}, []);
|
||
|
||
const handlePaste = useCallback((event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||
const clipboardFiles = event.clipboardData?.files;
|
||
if (!clipboardFiles || clipboardFiles.length === 0) return;
|
||
const imageFiles = Array.from(clipboardFiles).filter((file) => file.type.startsWith("image/"));
|
||
if (imageFiles.length === 0) return;
|
||
handleAttachmentFiles(imageFiles);
|
||
}, [handleAttachmentFiles]);
|
||
|
||
// Handle create session
|
||
const handleCreateSession = useCallback(
|
||
async (input: { agentId: string; modelProvider?: string; modelId?: string }) => {
|
||
try {
|
||
await createSession(input);
|
||
setShowNewDialog(false);
|
||
// On mobile, hide sidebar after selecting
|
||
if (isMobile) setSidebarVisible(false);
|
||
} catch {
|
||
addToast("Failed to create chat session", "error");
|
||
}
|
||
},
|
||
[createSession, addToast, isMobile],
|
||
);
|
||
|
||
const resizeComposer = useCallback((textarea?: HTMLTextAreaElement | null) => {
|
||
const composer = textarea ?? inputRef.current;
|
||
if (!composer) {
|
||
return;
|
||
}
|
||
|
||
composer.style.height = "auto";
|
||
composer.style.height = `${clampChatInputHeight(composer.scrollHeight)}px`;
|
||
}, []);
|
||
|
||
const handleComposerRef = useCallback((textarea: HTMLTextAreaElement | null) => {
|
||
inputRef.current = textarea;
|
||
if (!textarea) {
|
||
return;
|
||
}
|
||
|
||
resizeComposer(textarea);
|
||
}, [resizeComposer]);
|
||
|
||
useLayoutEffect(() => {
|
||
resizeComposer();
|
||
}, [chatScope, messageInput, activeSession?.id, rooms.activeRoom?.id, resizeComposer]);
|
||
|
||
const clearComposerState = useCallback(() => {
|
||
setMessageInput("");
|
||
if (activeDraftKey) {
|
||
try {
|
||
localStorage.removeItem(activeDraftKey);
|
||
} catch {
|
||
// Ignore storage errors.
|
||
}
|
||
}
|
||
setShowSkillMenu(false);
|
||
setSkillFilter("");
|
||
setMentionPopupVisible(false);
|
||
setMentionFilter("");
|
||
setMentionStartPos(-1);
|
||
setPendingAttachments((prev) => {
|
||
for (const attachment of prev) {
|
||
if (attachment.previewUrl) {
|
||
URL.revokeObjectURL(attachment.previewUrl);
|
||
}
|
||
}
|
||
return [];
|
||
});
|
||
}, [activeDraftKey]);
|
||
|
||
// Handle send message including pending attachment uploads.
|
||
const handleSend = useCallback(() => {
|
||
const trimmed = messageInput.trim();
|
||
const files = pendingAttachments.map((attachment) => attachment.file);
|
||
if ((!trimmed && files.length === 0) || !activeSession) return;
|
||
|
||
if (trimmed === "/clear" || trimmed === "/new") {
|
||
clearComposerState();
|
||
clearPendingMessage();
|
||
stopStreaming();
|
||
void createSession({
|
||
agentId: activeSession.agentId,
|
||
modelProvider: activeSession.modelProvider ?? undefined,
|
||
modelId: activeSession.modelId ?? undefined,
|
||
}).catch(() => {
|
||
addToast("Failed to clear conversation", "error");
|
||
});
|
||
return;
|
||
}
|
||
|
||
clearComposerState();
|
||
sendMessage(trimmed, files);
|
||
}, [
|
||
messageInput,
|
||
pendingAttachments,
|
||
activeSession,
|
||
clearComposerState,
|
||
stopStreaming,
|
||
clearPendingMessage,
|
||
createSession,
|
||
addToast,
|
||
sendMessage,
|
||
]);
|
||
|
||
|
||
const handleSendDispatch = useCallback(async () => {
|
||
const trimmed = messageInput.trim();
|
||
if (!trimmed) {
|
||
return;
|
||
}
|
||
|
||
if (chatRoomsEnabled && chatScope === "rooms") {
|
||
if (!rooms.activeRoom) {
|
||
return;
|
||
}
|
||
|
||
if (trimmed === "/clear" || trimmed === "/new") {
|
||
clearComposerState();
|
||
try {
|
||
await rooms.clearRoom(rooms.activeRoom.id);
|
||
} catch {
|
||
addToast("Failed to clear room conversation", "error");
|
||
}
|
||
return;
|
||
}
|
||
|
||
const previousInput = messageInput;
|
||
clearComposerState();
|
||
|
||
try {
|
||
await rooms.sendRoomMessage(trimmed, { files: pendingAttachments.map((attachment) => attachment.file) });
|
||
} catch (error) {
|
||
if (error instanceof RoomMessageDeliveredButReplyFailedError) {
|
||
const message = error.message.trim()
|
||
? error.message
|
||
: "Message sent, but assistant reply failed";
|
||
addToast(`Message sent, but assistant reply failed: ${message}`, "error");
|
||
return;
|
||
}
|
||
|
||
setMessageInput(previousInput);
|
||
const message = error instanceof Error && error.message.trim()
|
||
? error.message
|
||
: "Failed to send room message";
|
||
addToast(message, "error");
|
||
}
|
||
return;
|
||
}
|
||
|
||
handleSend();
|
||
}, [messageInput, pendingAttachments, chatRoomsEnabled, chatScope, rooms, rooms.clearRoom, clearComposerState, addToast, handleSend]);
|
||
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;
|
||
resizeComposer(inputRef.current);
|
||
inputRef.current.focus();
|
||
});
|
||
|
||
return nextInput;
|
||
});
|
||
|
||
setShowSkillMenu(false);
|
||
setSkillFilter("");
|
||
setHighlightedSkillIndex(0);
|
||
},
|
||
[resizeComposer],
|
||
);
|
||
|
||
const handleMentionSelect = useCallback(
|
||
(agent: Agent) => {
|
||
const textarea = inputRef.current;
|
||
if (!textarea || mentionStartPos < 0) {
|
||
return;
|
||
}
|
||
|
||
const selectionStart = textarea.selectionStart ?? mentionCursorPosRef.current;
|
||
const selectionEnd = textarea.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;
|
||
resizeComposer(inputRef.current);
|
||
inputRef.current.focus();
|
||
inputRef.current.setSelectionRange(nextCursorPos, nextCursorPos);
|
||
});
|
||
},
|
||
[mentionStartPos, messageInput, resizeComposer],
|
||
);
|
||
|
||
const insertHashMention = useCallback(
|
||
(nextInput: string, insertedToken: string) => {
|
||
const textarea = inputRef.current;
|
||
const cursorPos = textarea?.selectionStart ?? mentionCursorPosRef.current;
|
||
const mentionStart = messageInput.lastIndexOf("#", cursorPos);
|
||
const nextCursorPos = mentionStart >= 0
|
||
? mentionStart + insertedToken.length
|
||
: nextInput.length;
|
||
|
||
setMessageInput(nextInput);
|
||
fileMention.dismissMention();
|
||
setFileMentionPopupVisible(false);
|
||
|
||
window.requestAnimationFrame(() => {
|
||
if (!inputRef.current) return;
|
||
resizeComposer(inputRef.current);
|
||
inputRef.current.focus();
|
||
inputRef.current.setSelectionRange(nextCursorPos, nextCursorPos);
|
||
});
|
||
},
|
||
[fileMention, messageInput, resizeComposer],
|
||
);
|
||
|
||
// Handle input key down
|
||
const handleInputKeyDown = useCallback(
|
||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||
mentionCursorPosRef.current = e.currentTarget.selectionStart ?? mentionCursorPosRef.current;
|
||
|
||
// Handle file mention popup keyboard navigation first
|
||
if (fileMention.mentionActive && fileMention.combinedItems.length > 0) {
|
||
fileMention.handleKeyDown(e, messageInput);
|
||
if (e.key === "Enter" || e.key === "Tab") {
|
||
const item = fileMention.combinedItems[fileMention.selectedIndex];
|
||
if (item?.kind === "task") {
|
||
insertHashMention(fileMention.selectTask(item.task, messageInput), `#${item.task.id}`);
|
||
} else if (item?.kind === "file") {
|
||
insertHashMention(fileMention.selectFile(item.file, messageInput), `#${item.file.path}`);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (mentionPopupVisible && e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
if (filteredMentionAgents.length > 0) {
|
||
setMentionHighlightIndex((prev) => (prev + 1) % filteredMentionAgents.length);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (mentionPopupVisible && e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
if (filteredMentionAgents.length > 0) {
|
||
setMentionHighlightIndex((prev) =>
|
||
prev === 0 ? filteredMentionAgents.length - 1 : prev - 1,
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (mentionPopupVisible && e.key === "Enter") {
|
||
e.preventDefault();
|
||
const agentToSelect = filteredMentionAgents[mentionHighlightIndex] ?? filteredMentionAgents[0];
|
||
if (agentToSelect) {
|
||
handleMentionSelect(agentToSelect);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (mentionPopupVisible && e.key === "Escape") {
|
||
e.preventDefault();
|
||
setMentionPopupVisible(false);
|
||
setMentionFilter("");
|
||
setMentionStartPos(-1);
|
||
return;
|
||
}
|
||
|
||
if (showSkillMenu && e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
if (filteredSkills.length > 0) {
|
||
setHighlightedSkillIndex((prev) => (prev + 1) % filteredSkills.length);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (showSkillMenu && e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
if (filteredSkills.length > 0) {
|
||
setHighlightedSkillIndex((prev) =>
|
||
prev === 0 ? filteredSkills.length - 1 : prev - 1,
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (showSkillMenu && (e.key === "Enter" || e.key === "Tab") && filteredSkills.length > 0) {
|
||
e.preventDefault();
|
||
const skillToSelect = filteredSkills[highlightedSkillIndex] ?? filteredSkills[0];
|
||
if (skillToSelect) {
|
||
handleSkillSelect(skillToSelect);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (showSkillMenu && e.key === "Escape") {
|
||
e.preventDefault();
|
||
setShowSkillMenu(false);
|
||
return;
|
||
}
|
||
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
void handleSendDispatch();
|
||
}
|
||
},
|
||
[
|
||
mentionPopupVisible,
|
||
filteredMentionAgents,
|
||
mentionHighlightIndex,
|
||
handleMentionSelect,
|
||
showSkillMenu,
|
||
filteredSkills,
|
||
highlightedSkillIndex,
|
||
handleSkillSelect,
|
||
handleSendDispatch,
|
||
fileMention,
|
||
insertHashMention,
|
||
messageInput,
|
||
],
|
||
);
|
||
|
||
const updateMentionState = useCallback((value: string, cursorPos: number) => {
|
||
const mentionTriggerMatch = getMentionTriggerMatch(value, cursorPos);
|
||
if (mentionTriggerMatch) {
|
||
setMentionPopupVisible(true);
|
||
setMentionFilter(mentionTriggerMatch.filter);
|
||
setMentionStartPos(mentionTriggerMatch.start);
|
||
return;
|
||
}
|
||
|
||
setMentionPopupVisible(false);
|
||
setMentionFilter("");
|
||
setMentionStartPos(-1);
|
||
}, []);
|
||
|
||
// Handle textarea resize
|
||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||
const textarea = e.target;
|
||
const nextValue = textarea.value;
|
||
const cursorPos = textarea.selectionStart ?? nextValue.length;
|
||
|
||
// Resize BEFORE the state update so the textarea grows in the same frame
|
||
// the user typed in (matches QuickChat). Doing it after setMessageInput
|
||
// works in tests but can lose the height in production because React 18
|
||
// batches the state update and the controlled-component value reset can
|
||
// happen before our direct DOM height assignment lands.
|
||
resizeComposer(textarea);
|
||
|
||
mentionCursorPosRef.current = cursorPos;
|
||
setMessageInput(nextValue);
|
||
|
||
const skillTriggerMatch = getSkillTriggerMatch(nextValue);
|
||
if (skillTriggerMatch) {
|
||
setShowSkillMenu(true);
|
||
setSkillFilter(skillTriggerMatch.filter);
|
||
} else {
|
||
setShowSkillMenu(false);
|
||
setSkillFilter("");
|
||
}
|
||
|
||
updateMentionState(nextValue, cursorPos);
|
||
|
||
// Detect file mentions
|
||
fileMention.detectMention(nextValue, cursorPos);
|
||
setFileMentionPopupVisible(fileMention.mentionActive);
|
||
if (fileMention.mentionActive) {
|
||
updateFileMentionPosition(textarea);
|
||
}
|
||
}, [updateMentionState, resizeComposer]);
|
||
|
||
const handleInputSelectionChange = useCallback(
|
||
(e: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
||
const textarea = e.currentTarget;
|
||
const cursorPos = textarea.selectionStart ?? textarea.value.length;
|
||
mentionCursorPosRef.current = cursorPos;
|
||
updateMentionState(textarea.value, cursorPos);
|
||
|
||
// Detect file mentions
|
||
fileMention.detectMention(textarea.value, cursorPos);
|
||
setFileMentionPopupVisible(fileMention.mentionActive);
|
||
if (fileMention.mentionActive) {
|
||
updateFileMentionPosition(textarea);
|
||
}
|
||
},
|
||
[updateMentionState, fileMention, updateFileMentionPosition],
|
||
);
|
||
|
||
const handleInputKeyUp = useCallback(
|
||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if (e.key === "Escape") {
|
||
return;
|
||
}
|
||
handleInputSelectionChange(e);
|
||
},
|
||
[handleInputSelectionChange],
|
||
);
|
||
|
||
const handleInputBlur = useCallback(() => {
|
||
if (typeof window !== "undefined" && window.innerWidth <= 768) {
|
||
suppressVvShrinkRef.current = true;
|
||
if (suppressVvShrinkTimeoutRef.current !== null) {
|
||
window.clearTimeout(suppressVvShrinkTimeoutRef.current);
|
||
}
|
||
suppressVvShrinkTimeoutRef.current = window.setTimeout(() => {
|
||
suppressVvShrinkRef.current = false;
|
||
suppressVvShrinkTimeoutRef.current = null;
|
||
}, 450);
|
||
}
|
||
|
||
if (hideSkillMenuTimeoutRef.current !== null) {
|
||
window.clearTimeout(hideSkillMenuTimeoutRef.current);
|
||
}
|
||
|
||
hideSkillMenuTimeoutRef.current = window.setTimeout(() => {
|
||
setShowSkillMenu(false);
|
||
setMentionPopupVisible(false);
|
||
setMentionFilter("");
|
||
setMentionStartPos(-1);
|
||
setFileMentionPopupVisible(false);
|
||
fileMention.dismissMention();
|
||
hideSkillMenuTimeoutRef.current = null;
|
||
}, 120);
|
||
}, [fileMention]);
|
||
|
||
const handleInputFocus = useCallback(() => {
|
||
suppressVvShrinkRef.current = false;
|
||
if (suppressVvShrinkTimeoutRef.current !== null) {
|
||
window.clearTimeout(suppressVvShrinkTimeoutRef.current);
|
||
suppressVvShrinkTimeoutRef.current = null;
|
||
}
|
||
if (hideSkillMenuTimeoutRef.current !== null) {
|
||
window.clearTimeout(hideSkillMenuTimeoutRef.current);
|
||
hideSkillMenuTimeoutRef.current = null;
|
||
}
|
||
// iOS quirk: after the keyboard has been dismissed once, re-focusing
|
||
// an input leaves window.scrollY > 0 *and* visualViewport.offsetTop
|
||
// > 0 — the layout viewport drifts up, and the position:fixed
|
||
// useMobileScrollLock applies to a body that is no longer at the
|
||
// top of the document. Result: the message thread anchors above
|
||
// the visible viewport with a large blank area below it. Forcing
|
||
// scroll back to (0,0) on the focus event neutralizes the drift
|
||
// before lock applies. Done in a microtask so iOS finishes its
|
||
// own scroll-into-view first.
|
||
if (typeof window !== "undefined" && window.innerWidth <= 768) {
|
||
queueMicrotask(() => {
|
||
if (window.scrollY !== 0 || window.scrollX !== 0) {
|
||
window.scrollTo(0, 0);
|
||
}
|
||
});
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (suppressVvShrinkTimeoutRef.current !== null) {
|
||
window.clearTimeout(suppressVvShrinkTimeoutRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
// Handle archive
|
||
const handleArchive = useCallback(
|
||
async (id: string) => {
|
||
setContextMenu(null);
|
||
try {
|
||
await archiveSession(id);
|
||
addToast("Conversation archived", "success");
|
||
} catch {
|
||
addToast("Failed to archive conversation", "error");
|
||
}
|
||
},
|
||
[archiveSession, addToast],
|
||
);
|
||
|
||
// Handle delete
|
||
const handleDelete = useCallback(
|
||
async (id: string) => {
|
||
setConfirmDelete(null);
|
||
setContextMenu(null);
|
||
try {
|
||
await deleteSession(id);
|
||
addToast("Conversation deleted", "success");
|
||
} catch {
|
||
addToast("Failed to delete conversation", "error");
|
||
}
|
||
},
|
||
[deleteSession, addToast],
|
||
);
|
||
|
||
const persistSidebarWidth = useCallback((width: number) => {
|
||
try {
|
||
localStorage.setItem(CHAT_SIDEBAR_STORAGE_KEY, String(width));
|
||
} catch {
|
||
// Ignore storage errors.
|
||
}
|
||
}, []);
|
||
|
||
const handleResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||
if (isMobile) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
const resizeHandle = event.currentTarget;
|
||
if (typeof resizeHandle.setPointerCapture === "function") {
|
||
resizeHandle.setPointerCapture(event.pointerId);
|
||
}
|
||
|
||
const startX = event.clientX;
|
||
const startWidth = sidebarWidth;
|
||
let latestWidth = startWidth;
|
||
|
||
document.body.style.userSelect = "none";
|
||
|
||
const onPointerMove = (moveEvent: PointerEvent) => {
|
||
const deltaX = moveEvent.clientX - startX;
|
||
const nextWidth = Math.max(CHAT_SIDEBAR_MIN_WIDTH, Math.min(CHAT_SIDEBAR_MAX_WIDTH, startWidth + deltaX));
|
||
latestWidth = nextWidth;
|
||
setSidebarWidth(nextWidth);
|
||
persistSidebarWidth(nextWidth);
|
||
};
|
||
|
||
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);
|
||
persistSidebarWidth(latestWidth);
|
||
};
|
||
|
||
document.addEventListener("pointermove", onPointerMove);
|
||
document.addEventListener("pointerup", onPointerUp);
|
||
}, [isMobile, persistSidebarWidth, sidebarWidth]);
|
||
|
||
const handleResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||
if (isMobile) {
|
||
return;
|
||
}
|
||
|
||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
|
||
const step = event.shiftKey ? 50 : 10;
|
||
const delta = event.key === "ArrowLeft" ? -step : step;
|
||
const nextWidth = Math.max(CHAT_SIDEBAR_MIN_WIDTH, Math.min(CHAT_SIDEBAR_MAX_WIDTH, sidebarWidth + delta));
|
||
setSidebarWidth(nextWidth);
|
||
persistSidebarWidth(nextWidth);
|
||
}, [isMobile, persistSidebarWidth, sidebarWidth]);
|
||
|
||
// Handle session click
|
||
const handleSessionClick = useCallback(
|
||
(id: string) => {
|
||
const selectedSession = filteredSessions.find((session) => session.id === id);
|
||
markRead("direct", id, selectedSession?.lastMessageAt ?? selectedSession?.updatedAt);
|
||
selectSession(id);
|
||
setMobileSessionMenuOpen(false);
|
||
if (isMobile) setSidebarVisible(false);
|
||
},
|
||
[filteredSessions, isMobile, markRead, selectSession],
|
||
);
|
||
|
||
// Handle back to sidebar (mobile)
|
||
const handleBack = useCallback(() => {
|
||
selectSession("");
|
||
setSidebarVisible(true);
|
||
setMobileSessionMenuOpen(false);
|
||
}, [selectSession]);
|
||
|
||
const handleRoomBack = useCallback(() => {
|
||
rooms.selectRoom(null);
|
||
setSidebarVisible(true);
|
||
setMobileSessionMenuOpen(false);
|
||
}, [rooms]);
|
||
|
||
// Render empty state (no active session)
|
||
const renderEmptyState = () => {
|
||
return (
|
||
<div className="chat-empty-state">
|
||
<MessageSquare size={48} strokeWidth={1.5} />
|
||
<h2>Start a new conversation</h2>
|
||
<button className="btn btn-primary" onClick={() => setShowNewDialog(true)}>
|
||
<Plus size={16} />
|
||
New Chat
|
||
</button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const activeResolvedModel = resolveSessionProvider(
|
||
activeSession,
|
||
activeSession?.agentId ? (agentsMap.get(activeSession.agentId) ?? null) : null,
|
||
defaultModel,
|
||
);
|
||
const activeModelTag = formatModelTag(activeResolvedModel?.provider, activeResolvedModel?.modelId);
|
||
const activeModelProvider = activeResolvedModel?.provider ?? null;
|
||
const hasThreadInView = Boolean(activeSession || isStreaming || messages.length > 0);
|
||
const hasMobileDetailSelection = chatScope === "rooms" ? roomThreadActive : Boolean(activeSession);
|
||
const previousHasMobileDetailSelectionRef = useRef(hasMobileDetailSelection);
|
||
|
||
useEffect(() => {
|
||
const previousHasMobileDetailSelection = previousHasMobileDetailSelectionRef.current;
|
||
previousHasMobileDetailSelectionRef.current = hasMobileDetailSelection;
|
||
|
||
if (!isMobile) {
|
||
return;
|
||
}
|
||
|
||
if (previousHasMobileDetailSelection || !hasMobileDetailSelection) {
|
||
return;
|
||
}
|
||
|
||
// Mobile list/detail surfaces must stack a view entry on top of the
|
||
// shared browser-history nav entry so swipe-back returns to the list.
|
||
pushNav({
|
||
type: "view",
|
||
revert: chatScope === "rooms" ? handleRoomBack : handleBack,
|
||
});
|
||
}, [chatScope, handleBack, handleRoomBack, hasMobileDetailSelection, isMobile, pushNav]);
|
||
|
||
const threadHeaderTitle = activeSession?.agentId === FN_AGENT_ID
|
||
? (activeModelTag ?? "Fusion")
|
||
: activeSession?.title || agentsMap.get(activeSession?.agentId ?? "")?.name || activeSession?.agentId || "Chat";
|
||
|
||
const showThreadHeaderModelTag = Boolean(activeModelTag && activeModelTag !== threadHeaderTitle);
|
||
const showMobileSessionSwitcher = isMobile && chatScope === "direct" && !!activeSession;
|
||
|
||
const agentName =
|
||
agentsMap.get(activeSession?.agentId ?? "")?.name ||
|
||
(activeSession?.agentId === FN_AGENT_ID
|
||
? (activeModelTag ?? "Fusion")
|
||
: (activeSession?.agentId?.slice(0, 30) ?? "Fusion"));
|
||
|
||
// The model tag is already visible in the thread header — repeating it on
|
||
// every assistant message is noise. Keep it suppressed for regular chat
|
||
// (real agent name is the identity); QuickChat already collapses the tag
|
||
// because its `agentName` IS the model tag, so the per-message slot was
|
||
// always empty there too.
|
||
const showAssistantModelTag = false;
|
||
|
||
// In model-only chats (no real agent picked) the agent identity *is* the
|
||
// model name, which is already in the thread header. Repeating it on every
|
||
// assistant bubble is noise. Hide the per-message identity row entirely;
|
||
// the render-mode toggle still appears in a slim toolbar.
|
||
const hideAssistantIdentity = activeSession?.agentId === FN_AGENT_ID;
|
||
|
||
const pendingPreview = pendingMessage.length > 50
|
||
? `${pendingMessage.slice(0, 50)}…`
|
||
: pendingMessage;
|
||
|
||
const toggleAllAsPlain = useCallback(() => {
|
||
setShowAllAsPlain((value) => !value);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!mobileSessionMenuOpen) {
|
||
return;
|
||
}
|
||
|
||
const handlePointerDown = (event: MouseEvent) => {
|
||
if (mobileSessionMenuRef.current?.contains(event.target as Node)) {
|
||
return;
|
||
}
|
||
setMobileSessionMenuOpen(false);
|
||
};
|
||
|
||
document.addEventListener("mousedown", handlePointerDown);
|
||
return () => {
|
||
document.removeEventListener("mousedown", handlePointerDown);
|
||
};
|
||
}, [mobileSessionMenuOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!roomSwitcherOpen) {
|
||
return;
|
||
}
|
||
|
||
const handlePointerDown = (event: MouseEvent) => {
|
||
if (roomSwitcherRef.current?.contains(event.target as Node)) {
|
||
return;
|
||
}
|
||
setRoomSwitcherOpen(false);
|
||
};
|
||
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") {
|
||
setRoomSwitcherOpen(false);
|
||
}
|
||
};
|
||
|
||
document.addEventListener("mousedown", handlePointerDown);
|
||
document.addEventListener("keydown", handleKeyDown);
|
||
return () => {
|
||
document.removeEventListener("mousedown", handlePointerDown);
|
||
document.removeEventListener("keydown", handleKeyDown);
|
||
};
|
||
}, [roomSwitcherOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!isMobile || chatScope !== "direct" || sidebarVisible) {
|
||
setMobileSessionMenuOpen(false);
|
||
}
|
||
}, [isMobile, chatScope, sidebarVisible]);
|
||
|
||
useEffect(() => {
|
||
setRoomSwitcherOpen(false);
|
||
}, [rooms.activeRoom?.id]);
|
||
|
||
const setCopyFeedback = useCallback((messageId: string, feedback: CopyFeedbackState) => {
|
||
const existingTimeout = copyFeedbackTimeoutsRef.current.get(messageId);
|
||
if (existingTimeout) {
|
||
window.clearTimeout(existingTimeout);
|
||
}
|
||
|
||
setCopyFeedbackByMessageId((current) => ({ ...current, [messageId]: feedback }));
|
||
|
||
const timeoutId = window.setTimeout(() => {
|
||
setCopyFeedbackByMessageId((current) => {
|
||
const { [messageId]: _removed, ...rest } = current;
|
||
return rest;
|
||
});
|
||
copyFeedbackTimeoutsRef.current.delete(messageId);
|
||
}, 2000);
|
||
|
||
copyFeedbackTimeoutsRef.current.set(messageId, timeoutId);
|
||
}, []);
|
||
|
||
const handleCopyResponse = useCallback(async (messageId: string, content: string) => {
|
||
try {
|
||
if (!navigator.clipboard?.writeText) {
|
||
throw new Error("Clipboard API unavailable");
|
||
}
|
||
await navigator.clipboard.writeText(content);
|
||
setCopyFeedback(messageId, "success");
|
||
} catch {
|
||
setCopyFeedback(messageId, "error");
|
||
}
|
||
}, [setCopyFeedback]);
|
||
|
||
const renderAssistantContent = useCallback(
|
||
(content: string, forcePlain = false) => {
|
||
const showPlainText = forcePlain;
|
||
if (showPlainText) {
|
||
return <div className="chat-message-content chat-message-content--plain">{content}</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="chat-message-content chat-message-content--markdown">
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={chatMarkdownComponents}>
|
||
{content}
|
||
</ReactMarkdown>
|
||
</div>
|
||
);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const showProviderResponseCopy = activeSession?.agentId === FN_AGENT_ID;
|
||
|
||
const renderCopyAction = useCallback((messageId: string, content: string, testId?: string) => (
|
||
<button
|
||
type="button"
|
||
className={`btn-icon chat-message-copy-action${copyFeedbackByMessageId[messageId] === "success" ? " chat-message-copy-action--success" : ""}${copyFeedbackByMessageId[messageId] === "error" ? " chat-message-copy-action--error" : ""}`}
|
||
data-testid={testId ?? `chat-copy-response-${messageId}`}
|
||
aria-label={copyFeedbackByMessageId[messageId] === "success" ? "Response copied" : copyFeedbackByMessageId[messageId] === "error" ? "Copy failed" : "Copy response"}
|
||
onClick={() => {
|
||
void handleCopyResponse(messageId, content);
|
||
}}
|
||
>
|
||
{copyFeedbackByMessageId[messageId] === "success" ? <Check size={14} /> : <Copy size={14} />}
|
||
</button>
|
||
), [copyFeedbackByMessageId, handleCopyResponse]);
|
||
|
||
const handleScrollMessageToTop = useCallback((messageId: string) => {
|
||
const containerEl = messagesContainerRef.current;
|
||
if (!containerEl) return;
|
||
const selector = `[data-testid="chat-message-${messageId}"]`;
|
||
const targetEl = containerEl.querySelector<HTMLElement>(selector);
|
||
if (!targetEl) return;
|
||
|
||
const top = targetEl.getBoundingClientRect().top - containerEl.getBoundingClientRect().top + containerEl.scrollTop;
|
||
const prefersReducedMotion = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||
containerEl.scrollTo({ top, behavior: prefersReducedMotion ? "auto" : "smooth" });
|
||
}, []);
|
||
|
||
return (
|
||
<div className="chat-view">
|
||
{/* Sidebar */}
|
||
<div
|
||
className={`chat-sidebar${!sidebarVisible ? " chat-sidebar--hidden" : ""}`}
|
||
style={isMobile ? undefined : { width: `${sidebarWidth}px` }}
|
||
>
|
||
{chatRoomsEnabled && (
|
||
<div className="chat-sidebar-scope-toggle" role="tablist" data-testid="chat-sidebar-scope-toggle">
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
className={`chat-sidebar-scope-btn${chatScope === "direct" ? " chat-sidebar-scope-btn--active" : ""}`}
|
||
aria-selected={chatScope === "direct"}
|
||
data-testid="chat-sidebar-scope-direct"
|
||
onClick={() => setChatScope("direct")}
|
||
>
|
||
Direct
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
className={`chat-sidebar-scope-btn${chatScope === "rooms" ? " chat-sidebar-scope-btn--active" : ""}`}
|
||
aria-selected={chatScope === "rooms"}
|
||
data-testid="chat-sidebar-scope-rooms"
|
||
onClick={() => setChatScope("rooms")}
|
||
>
|
||
Rooms
|
||
</button>
|
||
</div>
|
||
)}
|
||
{!chatRoomsEnabled || chatScope === "direct" ? (
|
||
<>
|
||
{/* Search section */}
|
||
<div className="chat-sidebar-search-container">
|
||
<div className="chat-sidebar-search-wrapper">
|
||
<Search size={14} className="chat-sidebar-search-icon" />
|
||
<input
|
||
type="text"
|
||
className="chat-sidebar-search"
|
||
placeholder="Search conversations..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
data-testid="chat-search-input"
|
||
/>
|
||
</div>
|
||
</div>
|
||
{/* Session list section */}
|
||
<div className="chat-session-list chat-sidebar-list">
|
||
{sessionsLoading ? (
|
||
<div className="chat-empty-state chat-empty-state--padded">Loading...</div>
|
||
) : filteredSessions.length === 0 ? (
|
||
<div className="chat-empty-state chat-empty-state--padded">No conversations yet</div>
|
||
) : (
|
||
filteredSessions.map((session) => {
|
||
const isActive = activeSession?.id === session.id;
|
||
const showUnreadDot = !isActive && isUnread("direct", session.id, session.lastMessageAt ?? session.updatedAt);
|
||
const sessionResolvedModel = resolveSessionProvider(
|
||
session,
|
||
agentsMap.get(session.agentId) ?? null,
|
||
defaultModel,
|
||
);
|
||
const sessionModelTag = formatModelTag(sessionResolvedModel?.provider, sessionResolvedModel?.modelId) ?? "Fusion";
|
||
|
||
return (
|
||
<div
|
||
key={session.id}
|
||
className={`chat-session-item${isActive ? " chat-session-item--active" : ""}`}
|
||
onClick={() => handleSessionClick(session.id)}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
setContextMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
||
}}
|
||
data-testid={`chat-session-${session.id}`}
|
||
>
|
||
<button
|
||
className="chat-session-delete-btn"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setConfirmDelete(session.id);
|
||
}}
|
||
data-testid="chat-session-delete-btn"
|
||
aria-label="Delete conversation"
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
<div className="chat-session-title">
|
||
{session.title || "Untitled"}
|
||
{showUnreadDot ? (
|
||
<span
|
||
className="chat-unread-dot"
|
||
data-testid={`chat-unread-dot-${session.id}`}
|
||
aria-label="Unread messages"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
<div className="chat-session-preview">
|
||
{session.lastMessagePreview || "No messages"}
|
||
</div>
|
||
<div className="chat-session-meta">
|
||
<span className="chat-session-meta-model">
|
||
{sessionResolvedModel?.provider ? <ProviderIcon provider={sessionResolvedModel.provider} size="sm" /> : null}
|
||
<span>
|
||
{agentsMap.get(session.agentId)?.name ||
|
||
(session.agentId === FN_AGENT_ID ? sessionModelTag : session.agentId.slice(0, 30))}
|
||
</span>
|
||
</span>
|
||
<span>{session.updatedAt ? formatRelativeTime(session.updatedAt) : ""}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="chat-sidebar-rooms" data-testid="chat-sidebar-rooms">
|
||
{!isMobile && (
|
||
<div className="chat-sidebar-rooms-header" data-testid="chat-sidebar-rooms-header">
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm btn-primary"
|
||
data-testid="chat-create-room-btn"
|
||
onClick={() => setCreateRoomOpen(true)}
|
||
>
|
||
<Plus size={14} />
|
||
Create room
|
||
</button>
|
||
</div>
|
||
)}
|
||
{rooms.rooms.length === 0 ? (
|
||
<div className="chat-sidebar-rooms-empty" data-testid="chat-sidebar-rooms-empty">
|
||
No rooms yet.
|
||
</div>
|
||
) : (
|
||
<div className="chat-session-list chat-sidebar-list">
|
||
{rooms.rooms.map((room) => {
|
||
const isActive = rooms.activeRoom?.id === room.id;
|
||
const showUnreadDot = !isActive && isUnread("room", room.id, room.updatedAt);
|
||
return (
|
||
<div
|
||
key={room.id}
|
||
role="button"
|
||
tabIndex={0}
|
||
className={`chat-room-item${isActive ? " chat-room-item--active" : ""}`}
|
||
data-testid={`chat-room-item-${room.slug}`}
|
||
onClick={() => {
|
||
markRead("room", room.id, room.updatedAt);
|
||
rooms.selectRoom(room.id);
|
||
if (isMobile) {
|
||
setSidebarVisible(false);
|
||
}
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
markRead("room", room.id, room.updatedAt);
|
||
rooms.selectRoom(room.id);
|
||
if (isMobile) {
|
||
setSidebarVisible(false);
|
||
}
|
||
}
|
||
}}
|
||
>
|
||
<span className="chat-room-item-details">
|
||
<span className="chat-room-item-name-row">
|
||
<span className="chat-room-item-name">#{room.name}</span>
|
||
{showUnreadDot ? (
|
||
<span
|
||
className="chat-unread-dot"
|
||
data-testid={`chat-unread-dot-${room.id}`}
|
||
aria-label="Unread messages"
|
||
/>
|
||
) : null}
|
||
</span>
|
||
{isActive ? (
|
||
<span className="chat-room-item-meta">
|
||
{rooms.activeRoomMembers.length} {rooms.activeRoomMembers.length === 1 ? "member" : "members"}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="btn-icon chat-room-item-delete"
|
||
data-testid={`chat-room-delete-${room.slug}`}
|
||
aria-label={`Delete room ${room.name}`}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setConfirmDeleteRoomId(room.id);
|
||
}}
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{chatScope === "rooms" ? (
|
||
isMobile ? (
|
||
<div className="chat-sidebar-footer">
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm btn-primary chat-sidebar-footer-btn"
|
||
data-testid="chat-create-room-btn"
|
||
onClick={() => setCreateRoomOpen(true)}
|
||
>
|
||
<Plus size={14} />
|
||
Create room
|
||
</button>
|
||
</div>
|
||
) : null
|
||
) : (
|
||
<div className="chat-sidebar-footer">
|
||
<button
|
||
className="btn btn-sm btn-primary chat-sidebar-footer-btn"
|
||
onClick={() => setShowNewDialog(true)}
|
||
data-testid="chat-new-btn"
|
||
>
|
||
<Plus size={14} />
|
||
New Chat
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{!isMobile && sidebarVisible && (
|
||
<div
|
||
className="chat-sidebar-resize-handle"
|
||
role="separator"
|
||
aria-orientation="vertical"
|
||
aria-valuemin={CHAT_SIDEBAR_MIN_WIDTH}
|
||
aria-valuemax={CHAT_SIDEBAR_MAX_WIDTH}
|
||
aria-valuenow={sidebarWidth}
|
||
aria-label="Resize chat sidebar"
|
||
tabIndex={0}
|
||
onPointerDown={handleResizeStart}
|
||
onKeyDown={handleResizeKeyDown}
|
||
/>
|
||
)}
|
||
|
||
{/* Context Menu */}
|
||
{contextMenu && (
|
||
<div
|
||
className="chat-session-context-menu"
|
||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<button
|
||
onClick={() => handleArchive(contextMenu.sessionId)}
|
||
data-testid="chat-context-archive"
|
||
>
|
||
<Archive size={14} />
|
||
Archive
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setContextMenu(null);
|
||
setConfirmDelete(contextMenu.sessionId);
|
||
}}
|
||
data-testid="chat-context-delete"
|
||
>
|
||
<Trash2 size={14} />
|
||
Delete
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Confirm Delete Dialog */}
|
||
{confirmDelete && (
|
||
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setConfirmDelete(null)}>
|
||
<div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}>
|
||
<h3>Delete Conversation?</h3>
|
||
<p className="chat-view-delete-dialog-copy">
|
||
This action cannot be undone. All messages in this conversation will be permanently deleted.
|
||
</p>
|
||
<div className="chat-new-dialog-actions">
|
||
<button className="btn btn-sm" onClick={() => setConfirmDelete(null)}>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
className="btn btn-sm btn-danger"
|
||
onClick={() => void handleDelete(confirmDelete)}
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{chatRoomsEnabled && confirmDeleteRoomId && (
|
||
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setConfirmDeleteRoomId(null)}>
|
||
<div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}>
|
||
<h3>Delete Room?</h3>
|
||
<p className="chat-view-delete-dialog-copy">
|
||
This action cannot be undone. This room and all its messages will be permanently deleted.
|
||
</p>
|
||
<div className="chat-new-dialog-actions">
|
||
<button className="btn btn-sm" onClick={() => setConfirmDeleteRoomId(null)}>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
className="btn btn-sm btn-danger"
|
||
onClick={() => {
|
||
void (async () => {
|
||
try {
|
||
await rooms.deleteRoom(confirmDeleteRoomId);
|
||
setConfirmDeleteRoomId(null);
|
||
} catch {
|
||
addToast("Failed to delete room", "error");
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Thread */}
|
||
{chatRoomsEnabled && chatScope === "rooms" ? (
|
||
<div ref={chatThreadRef} className="chat-thread">
|
||
{rooms.activeRoom ? (
|
||
<>
|
||
<div className="chat-room-thread-header">
|
||
{isMobile && (
|
||
<button className="btn-icon" onClick={handleRoomBack} data-testid="chat-back-btn">
|
||
<ChevronLeft size={16} />
|
||
</button>
|
||
)}
|
||
<div className="chat-room-switcher-menu" ref={roomSwitcherRef}>
|
||
<button
|
||
type="button"
|
||
className="chat-room-switcher-trigger"
|
||
data-testid="chat-room-switcher-trigger"
|
||
aria-haspopup="menu"
|
||
aria-expanded={roomSwitcherOpen}
|
||
onClick={() => setRoomSwitcherOpen((open) => !open)}
|
||
>
|
||
<span className="chat-thread-header-title">#{rooms.activeRoom.name}</span>
|
||
<ChevronDown size={16} aria-hidden="true" />
|
||
</button>
|
||
{roomSwitcherOpen && (
|
||
<div
|
||
role="menu"
|
||
className="chat-room-switcher-dropdown"
|
||
data-testid="chat-room-switcher-dropdown"
|
||
>
|
||
{rooms.rooms.map((room) => (
|
||
<button
|
||
key={room.id}
|
||
type="button"
|
||
role="menuitem"
|
||
className={`chat-room-switcher-option${room.id === rooms.activeRoom?.id ? " chat-room-switcher-option--active" : ""}`}
|
||
data-testid={`chat-room-switcher-option-${room.id}`}
|
||
onClick={() => {
|
||
markRead("room", room.id, room.updatedAt);
|
||
rooms.selectRoom(room.id);
|
||
setRoomSwitcherOpen(false);
|
||
}}
|
||
>
|
||
#{room.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="chat-room-thread-members">
|
||
{rooms.activeRoomMembers.map((member) => (
|
||
<AgentAvatar
|
||
key={member.agentId}
|
||
agent={
|
||
agentsMap.get(member.agentId) ?? {
|
||
id: member.agentId,
|
||
name: member.agentId.slice(0, 30),
|
||
}
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
|
||
{rooms.messagesLoading ? (
|
||
<div className="chat-empty-state">Loading messages...</div>
|
||
) : rooms.messages.length === 0 ? (
|
||
<div className="chat-empty-state">No messages yet. Start the conversation!</div>
|
||
) : (
|
||
rooms.messages.map((message) => {
|
||
const senderName = message.senderAgentId ? (agentsMap.get(message.senderAgentId)?.name ?? message.senderAgentId.slice(0, 30)) : "You";
|
||
const roomMessage: ChatMessageInfo = {
|
||
id: message.id,
|
||
sessionId: message.roomId,
|
||
role: message.role,
|
||
content: message.content,
|
||
thinkingOutput: message.thinkingOutput ?? undefined,
|
||
toolCalls: undefined,
|
||
fallbackInfo: undefined,
|
||
attachments: message.attachments,
|
||
createdAt: message.createdAt,
|
||
};
|
||
return (
|
||
<ChatMessageItem
|
||
key={message.id}
|
||
message={roomMessage}
|
||
forcePlain={showAllAsPlain}
|
||
agentName={senderName}
|
||
hideAssistantIdentity={false}
|
||
showAssistantModelTag={false}
|
||
activeModelTag={null}
|
||
activeModelProvider={null}
|
||
activeSessionId={rooms.activeRoom?.id ?? null}
|
||
mentionAgentsByName={mentionAgentsByName}
|
||
roomContext={roomContext}
|
||
onScrollToTop={handleScrollMessageToTop}
|
||
/>
|
||
);
|
||
})
|
||
)}
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
{rooms.activeRoom && isUserScrolling && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm chat-jump-to-latest"
|
||
data-testid="chat-jump-to-latest"
|
||
onClick={() => scrollToBottom("fab-click")}
|
||
>
|
||
<ChevronDown size={14} />
|
||
Latest
|
||
</button>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="chat-room-empty-pane" data-testid="chat-rooms-empty-pane">Select a room or create one</div>
|
||
)}
|
||
|
||
{rooms.activeRoom && (
|
||
<div className="chat-input-area">
|
||
<div className="chat-input-row">
|
||
<div className="chat-input-wrapper">
|
||
<textarea
|
||
ref={handleComposerRef}
|
||
className="chat-input-textarea"
|
||
placeholder="Type a message..."
|
||
value={messageInput}
|
||
onChange={handleInputChange}
|
||
onKeyDown={handleInputKeyDown}
|
||
onKeyUp={handleInputKeyUp}
|
||
onClick={handleInputSelectionChange}
|
||
onBlur={handleInputBlur}
|
||
onFocus={handleInputFocus}
|
||
onTouchStart={(event) => {
|
||
if (typeof window === "undefined") return;
|
||
if (window.innerWidth > 768) return;
|
||
// iOS-only: preventDefault + programmatic focus avoids
|
||
// iOS's visual-viewport scroll on re-focus. On Android,
|
||
// preventDefault here blocks the soft keyboard from
|
||
// opening at all (programmatic focus() does not raise
|
||
// the keyboard on Android), so the input "focuses" but
|
||
// the keyboard never appears.
|
||
if (!isIOS()) return;
|
||
if (document.activeElement === event.currentTarget) return;
|
||
event.preventDefault();
|
||
event.currentTarget.focus({ preventScroll: true });
|
||
}}
|
||
rows={1}
|
||
data-testid="chat-input"
|
||
/>
|
||
<AgentMentionPopup
|
||
agents={mentionAgents}
|
||
filter={mentionFilter}
|
||
highlightedIndex={mentionHighlightIndex}
|
||
visible={mentionPopupVisible}
|
||
onSelect={handleMentionSelect}
|
||
position="below"
|
||
roomMemberIds={roomContext?.memberIds}
|
||
roomName={roomContext?.roomName}
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="chat-input-send"
|
||
// Keep keyboard up when sending. preventDefault fires on
|
||
// pointerdown for touch pointers (BEFORE iOS blurs the
|
||
// textarea — the synthesized mousedown is too late on
|
||
// iOS), and on mousedown for desktop. Crucially we do NOT
|
||
// call preventDefault on touchstart and we do NOT run the
|
||
// action here — both of those broke quick taps. Click
|
||
// still fires from the iOS touch sequence and runs the
|
||
// action reliably.
|
||
onPointerDown={(event) => {
|
||
if (event.pointerType && event.pointerType !== "mouse") {
|
||
event.preventDefault();
|
||
}
|
||
}}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
onClick={() => {
|
||
void handleSendDispatch();
|
||
}}
|
||
disabled={!messageInput.trim()}
|
||
data-testid="chat-send-btn"
|
||
style={{ touchAction: "manipulation" }}
|
||
>
|
||
<Send size={16} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div ref={chatThreadRef} className="chat-thread">
|
||
{/* Header - always rendered in desktop/tablet, only rendered in mobile when viewing a thread */}
|
||
{(hasThreadInView || !isMobile) && (
|
||
<div className="chat-thread-header">
|
||
{isMobile && hasThreadInView && (
|
||
<button className="btn-icon" onClick={handleBack} data-testid="chat-back-btn">
|
||
<ChevronLeft size={16} />
|
||
</button>
|
||
)}
|
||
<div className="chat-thread-header-identity" data-testid="chat-thread-header-identity">
|
||
{showMobileSessionSwitcher ? (
|
||
<div className="chat-mobile-session-menu" ref={mobileSessionMenuRef}>
|
||
<button
|
||
type="button"
|
||
className="btn chat-mobile-session-trigger"
|
||
data-testid="chat-mobile-session-trigger"
|
||
aria-haspopup="menu"
|
||
aria-expanded={mobileSessionMenuOpen}
|
||
onClick={() => setMobileSessionMenuOpen((open) => !open)}
|
||
>
|
||
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="md" /> : <Bot size={16} />}
|
||
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
|
||
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||
<ChevronDown size={16} aria-hidden="true" />
|
||
</button>
|
||
{mobileSessionMenuOpen && (
|
||
<div className="chat-mobile-session-dropdown" role="menu" data-testid="chat-mobile-session-dropdown">
|
||
{filteredSessions.map((session) => (
|
||
<button
|
||
key={session.id}
|
||
type="button"
|
||
role="menuitem"
|
||
className={`chat-mobile-session-option${activeSession?.id === session.id ? " chat-mobile-session-option--active" : ""}`}
|
||
data-testid={`chat-mobile-session-option-${session.id}`}
|
||
onClick={() => handleSessionClick(session.id)}
|
||
>
|
||
<span className="chat-mobile-session-option-title">{session.title || "Untitled"}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<>
|
||
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="md" /> : <Bot size={16} />}
|
||
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
|
||
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||
</>
|
||
)}
|
||
</div>
|
||
{hasThreadInView && (
|
||
<button
|
||
type="button"
|
||
className={`chat-thread-header-render-toggle${showAllAsPlain ? " chat-thread-header-render-toggle--plain" : ""}`}
|
||
data-testid="chat-thread-render-toggle"
|
||
aria-label={showAllAsPlain ? "Show all messages as rendered Markdown" : "Show all messages as plain text"}
|
||
onClick={toggleAllAsPlain}
|
||
>
|
||
{showAllAsPlain ? <EyeOff size={14} /> : <Eye size={14} />}
|
||
</button>
|
||
)}
|
||
{!isMobile && (
|
||
<button
|
||
className="btn btn-sm btn-primary chat-thread-header-new-chat"
|
||
onClick={() => setShowNewDialog(true)}
|
||
data-testid="chat-thread-new-chat-btn"
|
||
>
|
||
<Plus size={14} />
|
||
New Chat
|
||
</button>
|
||
)}
|
||
|
||
</div>
|
||
)}
|
||
|
||
{/* Messages */}
|
||
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
|
||
{isStreaming ? (
|
||
<>
|
||
{messages.map((message) => (
|
||
<ChatMessageItem
|
||
key={message.id}
|
||
message={message}
|
||
forcePlain={showAllAsPlain}
|
||
agentName={agentName}
|
||
hideAssistantIdentity={hideAssistantIdentity}
|
||
showAssistantModelTag={showAssistantModelTag}
|
||
activeModelTag={activeModelTag}
|
||
activeModelProvider={activeModelProvider}
|
||
activeSessionId={activeSession?.id ?? null}
|
||
mentionAgentsByName={mentionAgentsByName}
|
||
roomContext={null}
|
||
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
||
onScrollToTop={handleScrollMessageToTop}
|
||
/>
|
||
))}
|
||
<div className="chat-message chat-message--assistant chat-message--streaming">
|
||
{!hideAssistantIdentity && (
|
||
<div className="chat-message-avatar">
|
||
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}
|
||
<span>{agentName}</span>
|
||
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||
</div>
|
||
)}
|
||
{streamingText ? (
|
||
renderAssistantContent(streamingText, showAllAsPlain)
|
||
) : (
|
||
<div className="chat-message-content chat-message-content--waiting">
|
||
{streamingThinking ? "Thinking…" : "Connecting…"}
|
||
</div>
|
||
)}
|
||
{showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")}
|
||
{renderToolCalls(streamingToolCalls)}
|
||
{streamingThinking && (
|
||
<details className="chat-message-thinking">
|
||
<summary>Thinking</summary>
|
||
<pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre>
|
||
</details>
|
||
)}
|
||
<div className="chat-typing-indicator">
|
||
<span />
|
||
<span />
|
||
<span />
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : messagesLoading ? (
|
||
<div className="chat-empty-state">Loading messages...</div>
|
||
) : messages.length === 0 && !activeSession ? (
|
||
renderEmptyState()
|
||
) : messages.length === 0 && activeSession ? (
|
||
<div className="chat-empty-state">No messages yet. Start the conversation!</div>
|
||
) : (
|
||
<>
|
||
{messages.map((message) => (
|
||
<ChatMessageItem
|
||
key={message.id}
|
||
message={message}
|
||
forcePlain={showAllAsPlain}
|
||
agentName={agentName}
|
||
hideAssistantIdentity={hideAssistantIdentity}
|
||
showAssistantModelTag={showAssistantModelTag}
|
||
activeModelTag={activeModelTag}
|
||
activeModelProvider={activeModelProvider}
|
||
activeSessionId={activeSession?.id ?? null}
|
||
mentionAgentsByName={mentionAgentsByName}
|
||
roomContext={null}
|
||
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
||
onScrollToTop={handleScrollMessageToTop}
|
||
/>
|
||
))}
|
||
</>
|
||
)}
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
{isUserScrolling && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm chat-jump-to-latest"
|
||
data-testid="chat-jump-to-latest"
|
||
onClick={() => scrollToBottom("fab-click")}
|
||
>
|
||
<ChevronDown size={14} />
|
||
Latest
|
||
</button>
|
||
)}
|
||
|
||
{/* Input */}
|
||
{activeSession && (
|
||
<div className="chat-input-area">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*,.txt,.json,.yaml,.yml,.log,.csv,.xml,.md"
|
||
multiple
|
||
style={{ display: "none" }}
|
||
onChange={(event) => {
|
||
handleAttachmentFiles(event.target.files);
|
||
event.target.value = "";
|
||
}}
|
||
/>
|
||
{showSkillMenu && (
|
||
<div className="chat-skill-menu" data-testid="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={(e) => e.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>
|
||
)}
|
||
{pendingAttachments.length > 0 && (
|
||
<div className="chat-attachment-previews" data-testid="chat-attachment-previews">
|
||
{pendingAttachments.map((attachment, index) => (
|
||
<div
|
||
key={attachment.previewUrl || `${attachment.file.name}-${index}`}
|
||
className="chat-attachment-preview"
|
||
data-testid={`chat-attachment-preview-${index}`}
|
||
>
|
||
{attachment.previewUrl ? (
|
||
<img src={attachment.previewUrl} alt={attachment.file.name} />
|
||
) : (
|
||
<span className="chat-attachment-preview-name">{attachment.file.name}</span>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="chat-attachment-remove"
|
||
onClick={() => removeAttachment(index)}
|
||
data-testid={`chat-attachment-remove-${index}`}
|
||
aria-label={`Remove ${attachment.file.name}`}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="chat-input-row">
|
||
<button
|
||
type="button"
|
||
className="btn-icon chat-attach-btn"
|
||
data-testid="chat-attach-btn"
|
||
aria-label="Attach files"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
>
|
||
<Paperclip size={16} />
|
||
</button>
|
||
<div
|
||
className={`chat-input-wrapper${isDragOver ? " chat-input-wrapper--dragover" : ""}`}
|
||
onDragOver={(event) => {
|
||
event.preventDefault();
|
||
setIsDragOver(true);
|
||
}}
|
||
onDragLeave={() => setIsDragOver(false)}
|
||
onDrop={(event) => {
|
||
event.preventDefault();
|
||
setIsDragOver(false);
|
||
handleAttachmentFiles(event.dataTransfer.files);
|
||
}}
|
||
>
|
||
<textarea
|
||
ref={handleComposerRef}
|
||
className="chat-input-textarea"
|
||
placeholder="Type a message..."
|
||
value={messageInput}
|
||
onChange={handleInputChange}
|
||
onKeyDown={handleInputKeyDown}
|
||
onKeyUp={handleInputKeyUp}
|
||
onClick={handleInputSelectionChange}
|
||
onBlur={handleInputBlur}
|
||
onFocus={handleInputFocus}
|
||
onPaste={handlePaste}
|
||
onTouchStart={(event) => {
|
||
if (typeof window === "undefined") return;
|
||
if (window.innerWidth > 768) return;
|
||
// iOS-only: see comment on the other chat-input touchstart
|
||
// handler above. On Android, preventDefault blocks the
|
||
// soft keyboard from opening.
|
||
if (!isIOS()) return;
|
||
if (document.activeElement === event.currentTarget) return;
|
||
event.preventDefault();
|
||
event.currentTarget.focus({ preventScroll: true });
|
||
}}
|
||
rows={1}
|
||
data-testid="chat-input"
|
||
/>
|
||
<AgentMentionPopup
|
||
agents={mentionAgents}
|
||
filter={mentionFilter}
|
||
highlightedIndex={mentionHighlightIndex}
|
||
visible={mentionPopupVisible}
|
||
onSelect={handleMentionSelect}
|
||
position="below"
|
||
roomMemberIds={roomContext?.memberIds}
|
||
roomName={roomContext?.roomName}
|
||
/>
|
||
<FileMentionPopup
|
||
visible={fileMention.mentionActive && !mentionPopupVisible}
|
||
position={fileMentionPosition}
|
||
tasks={fileMention.tasks}
|
||
files={fileMention.files}
|
||
selectedIndex={fileMention.selectedIndex}
|
||
onSelectTask={(task) => {
|
||
insertHashMention(fileMention.selectTask(task, messageInput), `#${task.id}`);
|
||
}}
|
||
onSelectFile={(file) => {
|
||
insertHashMention(fileMention.selectFile(file, messageInput), `#${file.path}`);
|
||
}}
|
||
loading={fileMention.loading}
|
||
/>
|
||
{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>
|
||
{isStreaming ? (
|
||
<button
|
||
className="chat-input-stop"
|
||
onClick={stopStreaming}
|
||
aria-label="Stop generation"
|
||
data-testid="chat-stop-btn"
|
||
>
|
||
<Square size={14} />
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="chat-input-send"
|
||
// Keep keyboard up when sending. preventDefault fires on
|
||
// pointerdown for touch pointers (BEFORE iOS blurs the
|
||
// textarea — the synthesized mousedown is too late on
|
||
// iOS), and on mousedown for desktop. Crucially we do NOT
|
||
// call preventDefault on touchstart and we do NOT run the
|
||
// action here — both of those broke quick taps. Click
|
||
// still fires from the iOS touch sequence and runs the
|
||
// action reliably.
|
||
onPointerDown={(event) => {
|
||
if (event.pointerType && event.pointerType !== "mouse") {
|
||
event.preventDefault();
|
||
}
|
||
}}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
onClick={() => {
|
||
void handleSend();
|
||
}}
|
||
disabled={!messageInput.trim() && pendingAttachments.length === 0}
|
||
data-testid="chat-send-btn"
|
||
style={{ touchAction: "manipulation" }}
|
||
>
|
||
<Send size={16} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{chatRoomsEnabled && (
|
||
<CreateRoomModal
|
||
isOpen={createRoomOpen}
|
||
onClose={() => setCreateRoomOpen(false)}
|
||
projectId={projectId}
|
||
existingRoomNames={rooms.rooms.map((room) => room.name)}
|
||
onCreate={async (draft) => {
|
||
await rooms.createRoom({ name: draft.name, memberAgentIds: draft.memberAgentIds });
|
||
if (chatScope !== "rooms") {
|
||
setChatScope("rooms");
|
||
}
|
||
setCreateRoomOpen(false);
|
||
if (isMobile) {
|
||
setSidebarVisible(false);
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* New Chat Dialog (rendered at root level) */}
|
||
{showNewDialog && (
|
||
<NewChatDialog
|
||
projectId={projectId}
|
||
onClose={() => setShowNewDialog(false)}
|
||
onCreate={handleCreateSession}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|