4076 lines
157 KiB
TypeScript
4076 lines
157 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,
|
||
Pencil,
|
||
ChevronLeft,
|
||
Bot,
|
||
Square,
|
||
Eye,
|
||
EyeOff,
|
||
Paperclip,
|
||
File,
|
||
Wrench,
|
||
ChevronDown,
|
||
Copy,
|
||
Check,
|
||
TriangleAlert,
|
||
ArrowUpToLine,
|
||
Maximize2,
|
||
Minimize2,
|
||
X,
|
||
Hash,
|
||
} 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 { ChatQuestionResponse } from "./ChatQuestionResponse";
|
||
import { ProviderIcon } from "./ProviderIcon";
|
||
import { AgentMentionPopup } from "./AgentMentionPopup";
|
||
import { AgentAvatar } from "./AgentAvatar";
|
||
import { FileMentionPopup } from "./FileMentionPopup";
|
||
import { CreateRoomModal } from "./CreateRoomModal";
|
||
import { CliChatSurface, type CliChatTier } from "./CliChatSurface";
|
||
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 { useMobileKeyboardViewportLock, isIOS } from "../hooks/useMobileScrollLock";
|
||
import { matchesAgentMentionFilter } from "./mentionMatching";
|
||
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||
import { parseQuestionToolCall } from "../utils/parseQuestionToolCall";
|
||
import { useTranslation } from "react-i18next";
|
||
import type { TFunction } from "i18next";
|
||
import { ViewHeader } from "./ViewHeader";
|
||
|
||
export interface ChatViewProps {
|
||
projectId?: string;
|
||
addToast: (msg: string, type?: "success" | "error" | "warning") => void;
|
||
experimentalFeatures?: Record<string, boolean>;
|
||
floating?: boolean;
|
||
onPopOut?: () => void;
|
||
onMaximize?: () => void;
|
||
onMinimize?: () => void;
|
||
onClose?: () => void;
|
||
}
|
||
|
||
// 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;
|
||
const TABLET_INPUT_MAX_HEIGHT_PX = 200;
|
||
/** Canonical definition lives in packages/dashboard/src/chat.ts (ROOM_SKIP_SENTINEL). */
|
||
const ROOM_SKIP_SENTINEL = "__SKIP__";
|
||
let chatViewWasPreviouslyInactive = false;
|
||
|
||
export function resolveChatInputOverflowY(
|
||
scrollHeight: number,
|
||
maxHeight: number = CHAT_INPUT_MAX_HEIGHT_PX,
|
||
): "auto" | "hidden" {
|
||
return scrollHeight > maxHeight ? "auto" : "hidden";
|
||
}
|
||
|
||
export function clampChatInputHeight(scrollHeight: number, maxHeight: number = CHAT_INPUT_MAX_HEIGHT_PX): 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, maxHeight));
|
||
}
|
||
|
||
function formatRelativeTime(dateStr: string, t: TFunction<"app">): 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 t("chat.relativeTimeJustNow", "just now");
|
||
if (diffMins < 60) return t("chat.relativeTimeMinutes", "{{count}}m ago", { count: diffMins });
|
||
if (diffHours < 24) return t("chat.relativeTimeHours", "{{count}}h ago", { count: diffHours });
|
||
if (diffDays < 7) return t("chat.relativeTimeDays", "{{count}}d ago", { count: diffDays });
|
||
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"], t: (key: string, defaultValue: string) => string): 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">{t("chat.failureReferenceLabel", "Reference")}</span>
|
||
<span className="chat-message-failure-reference-value">{referenceLabel}</span>
|
||
{referenceHref ? (
|
||
<a className="btn btn-sm chat-message-failure-reference-link" href={referenceHref}>
|
||
{t("chat.openMailboxMessage", "Open mailbox message")}
|
||
</a>
|
||
) : (
|
||
<details className="chat-message-failure-reference-details">
|
||
<summary className="btn btn-sm chat-message-failure-reference-link">{t("chat.viewFailureDetails", "View failure details")}</summary>
|
||
<dl className="chat-message-failure-reference-meta" id={referenceDetailsId}>
|
||
<div>
|
||
<dt>{t("chat.failureReferenceKind", "Kind")}</dt>
|
||
<dd>{reference.kind}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>{t("chat.failureReferenceId", "ID")}</dt>
|
||
<dd>{reference.id}</dd>
|
||
</div>
|
||
{reference.label && (
|
||
<div>
|
||
<dt>{t("chat.failureReferenceMetaLabel", "Label")}</dt>
|
||
<dd>{reference.label}</dd>
|
||
</div>
|
||
)}
|
||
</dl>
|
||
</details>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function renderToolCalls(
|
||
toolCalls: ToolCallInfo[] | undefined,
|
||
t: (key: string, defaultValue: string, opts?: Record<string, unknown>) => string,
|
||
options?: {
|
||
isAwaitingAnswer?: boolean;
|
||
submittedAnswer?: string;
|
||
onQuestionSubmit?: (answerText: string, structured: Record<string, unknown>) => void;
|
||
},
|
||
): ReactNode {
|
||
if (!toolCalls || toolCalls.length === 0) return null;
|
||
|
||
const renderToolCallItem = (toolCall: ToolCallInfo, index: number) => {
|
||
const parsedQuestion = parseQuestionToolCall(toolCall);
|
||
if (parsedQuestion) {
|
||
const isAwaitingAnswer = options?.isAwaitingAnswer === true;
|
||
return (
|
||
<ChatQuestionResponse
|
||
key={`${toolCall.toolName}-${index}`}
|
||
parsed={parsedQuestion}
|
||
answered={!isAwaitingAnswer}
|
||
submittedAnswer={options?.submittedAnswer}
|
||
disabled={!isAwaitingAnswer}
|
||
onSubmit={(answerText, structured) => options?.onQuestionSubmit?.(answerText, structured)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
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
|
||
? `${t("chat.toolCallResultPrefix", "result")}: ${resultSummary}`
|
||
: argsSummary
|
||
? `${t("chat.toolCallArgsPrefix", "args")}: ${argsSummary}`
|
||
: null;
|
||
const statusLabel = isRunning ? t("chat.toolCallStatusRunning", "running") : isError ? t("chat.toolCallStatusError", "error") : t("chat.toolCallStatusCompleted", "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">{t("chat.toolCallArgsPrefix", "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">{t("chat.toolCallResultPrefix", "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>{t("chat.toolCallsHeader", "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} ${t("chat.toolCallStatusRunning", "running")})`
|
||
: errorCount > 0
|
||
? `(${errorCount} ${errorCount === 1 ? t("chat.toolCallStatusError", "error") : t("chat.toolCallStatusErrors", "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">{t("chat.toolCallsCount", "{{count}} tool calls", { count: toolCalls.length })}</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;
|
||
defaultModel: DefaultModelSelection;
|
||
onClose: () => void;
|
||
onCreate: (input: { agentId: string; modelProvider?: string; modelId?: string }) => void;
|
||
}
|
||
|
||
function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDialogProps) {
|
||
const { t } = useTranslation("app");
|
||
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 defaultModelValue = defaultModel.provider && defaultModel.modelId
|
||
? `${defaultModel.provider}/${defaultModel.modelId}`
|
||
: "";
|
||
const [selectedModel, setSelectedModel] = useState<string>(defaultModelValue);
|
||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>(cachedFavoriteProviders);
|
||
const [favoriteModels, setFavoriteModels] = useState<string[]>(cachedFavoriteModels);
|
||
|
||
useEffect(() => {
|
||
setFavoriteProviders(cachedFavoriteProviders);
|
||
}, [cachedFavoriteProviders]);
|
||
|
||
useEffect(() => {
|
||
setFavoriteModels(cachedFavoriteModels);
|
||
}, [cachedFavoriteModels]);
|
||
|
||
useEffect(() => {
|
||
if (!defaultModelValue) {
|
||
return;
|
||
}
|
||
setSelectedModel((current) => current || defaultModelValue);
|
||
}, [defaultModelValue]);
|
||
|
||
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 resolvedModel = selectedModel || defaultModelValue;
|
||
|
||
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||
e.preventDefault();
|
||
|
||
if (chatMode === "agent") {
|
||
if (!selectedAgentId) return;
|
||
onCreate({ agentId: selectedAgentId });
|
||
return;
|
||
}
|
||
|
||
// model mode
|
||
if (!resolvedModel) return;
|
||
const slashIdx = resolvedModel.indexOf("/");
|
||
if (slashIdx <= 0) return;
|
||
const modelProvider = resolvedModel.slice(0, slashIdx);
|
||
const modelId = resolvedModel.slice(slashIdx + 1);
|
||
onCreate({ agentId: FN_AGENT_ID, modelProvider, modelId });
|
||
};
|
||
|
||
const isSubmitDisabled =
|
||
chatMode === "agent" ? !selectedAgentId : !resolvedModel;
|
||
|
||
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>{t("chat.newChatTitle", "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");
|
||
}}
|
||
>
|
||
{t("chat.newChatModeAgent", "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("");
|
||
setSelectedModel((current) => current || defaultModelValue);
|
||
}}
|
||
>
|
||
{t("chat.newChatModeModel", "Model")}
|
||
</button>
|
||
</div>
|
||
<form onSubmit={handleSubmit}>
|
||
{chatMode === "agent" && (
|
||
<label className="chat-new-dialog-model-label">
|
||
{t("chat.newChatModeAgent", "Agent")}
|
||
{agentsLoading ? (
|
||
<div className="chat-new-dialog-loading">{t("chat.loadingAgents", "Loading agents...")}</div>
|
||
) : agents.length === 0 ? (
|
||
<div className="chat-new-dialog-empty">{t("chat.noAgentsAvailable", "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">{t("chat.loadingModels", "Loading models...")}</div>
|
||
) : (
|
||
<CustomModelDropdown
|
||
models={models}
|
||
value={selectedModel}
|
||
onChange={setSelectedModel}
|
||
label={t("chat.newChatModeModel", "Model")}
|
||
placeholder={t("chat.selectModel", "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}>
|
||
{t("chat.cancel", "Cancel")}
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="btn btn-sm btn-primary"
|
||
disabled={isSubmitDisabled}
|
||
>
|
||
{t("chat.create", "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;
|
||
isAwaitingQuestionAnswer: boolean;
|
||
submittedQuestionAnswer?: string;
|
||
onQuestionSubmit: (answerText: string, structured: Record<string, unknown>) => void;
|
||
}
|
||
|
||
function findSubmittedQuestionAnswer(messages: ChatMessageInfo[], messageIndex: number): string | undefined {
|
||
return messages.slice(messageIndex + 1).find((message) => message.role === "user")?.content;
|
||
}
|
||
|
||
// 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,
|
||
isAwaitingQuestionAnswer,
|
||
submittedQuestionAnswer,
|
||
onQuestionSubmit,
|
||
}: ChatMessageItemProps) {
|
||
const { t } = useTranslation("app");
|
||
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 ? t("chat.mentionNonMember", "Not a member of {{roomName}}", { roomName: 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">{t("chat.responseFailed", "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>{t("chat.failureDetails", "Failure details")}</span>
|
||
</summary>
|
||
{failureInfo.detail && <pre className="chat-message-failure-detail">{linkifyFilePaths(failureInfo.detail)}</pre>}
|
||
{renderFailureReference(failureInfo.reference, t)}
|
||
</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={t("chat.scrollMessageToTop", "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, t, {
|
||
isAwaitingAnswer: isAwaitingQuestionAnswer,
|
||
submittedAnswer: submittedQuestionAnswer,
|
||
onQuestionSubmit,
|
||
})}
|
||
{message.thinkingOutput && (
|
||
<details className="chat-message-thinking">
|
||
<summary>{t("chat.thinking", "Thinking")}</summary>
|
||
<pre className="chat-message-thinking-content">{linkifyFilePaths(message.thinkingOutput)}</pre>
|
||
</details>
|
||
)}
|
||
{renderedAttachments}
|
||
<div className="chat-message-time">{formatRelativeTime(message.createdAt, t)}</div>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
export function ChatView({ projectId, addToast, floating = false, onPopOut, onMaximize, onMinimize, onClose }: ChatViewProps) {
|
||
const { t } = useTranslation("app");
|
||
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,
|
||
renameSession,
|
||
deleteSession,
|
||
sendMessage,
|
||
stopStreaming,
|
||
pendingMessage,
|
||
clearPendingMessage,
|
||
loadMoreMessages,
|
||
hasMoreMessages,
|
||
searchQuery,
|
||
setSearchQuery,
|
||
filteredSessions,
|
||
agentsMap: chatAgentsMap,
|
||
} = useChat(projectId, addToast);
|
||
|
||
const [showNewDialog, setShowNewDialog] = useState(false);
|
||
/* FNXC:ChatRooms 2026-06-23-01:28: Chat Rooms graduated from Experimental; stale false flags should not hide rooms in the main view, popout modal, or quick-chat surfaces. */
|
||
const chatRoomsEnabled = 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 [renameDialog, setRenameDialog] = useState<{ sessionId: string; title: string } | null>(null);
|
||
const [renameTitle, setRenameTitle] = useState("");
|
||
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 loadMoreSentinelRef = 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;
|
||
capturedAtMs: number;
|
||
} | 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);
|
||
// Deferred drift-reset scheduled on blur; cancelled on the next focus so a
|
||
// quick re-tap never scrolls the document while iOS is raising the keyboard.
|
||
const blurScrollResetTimeoutRef = 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 roomSendInFlightRef = useRef(false);
|
||
/*
|
||
FNXC:ChatSendDedupe 2026-06-17-08:36:
|
||
FN-6576 refines FN-6563 by matching QuickChatFAB's two-latch touch contract: pointerdown/touchstart claim a per-input-task gesture so one mobile tap sends exactly once, while the separate 700ms latch is consumed only by a trailing click. A suppressed iOS click must never leave the long latch blocking the next tap; a send-to-stop DOM swap must consume the trailing click without swallowing a genuine later stop tap.
|
||
*/
|
||
const handledSendTouchRef = useRef(false);
|
||
const handledSendTouchTimerRef = useRef<number | null>(null);
|
||
const touchActionGestureRef = useRef(false);
|
||
const mode = useViewportMode();
|
||
const isMobile = mode === "mobile";
|
||
const isTablet = mode === "tablet";
|
||
const chatViewRef = useRef<HTMLDivElement>(null);
|
||
const [floatingNarrow, setFloatingNarrow] = useState(false);
|
||
/*
|
||
FNXC:ChatModal 2026-06-22-14:38:
|
||
The popped-out full Chat modal is resizable, so responsive behavior must follow the modal's own width, not only the browser viewport. When the floating Chat surface narrows to mobile width, switch to the mobile list/detail layout and hide the sidebar after a chat is opened.
|
||
*/
|
||
useLayoutEffect(() => {
|
||
if (!floating) {
|
||
setFloatingNarrow(false);
|
||
return;
|
||
}
|
||
|
||
const element = chatViewRef.current;
|
||
if (!element || typeof ResizeObserver === "undefined") {
|
||
return;
|
||
}
|
||
|
||
const update = () => {
|
||
setFloatingNarrow(element.getBoundingClientRect().width <= 768);
|
||
};
|
||
|
||
update();
|
||
const observer = new ResizeObserver(update);
|
||
observer.observe(element);
|
||
return () => observer.disconnect();
|
||
}, [floating]);
|
||
const isChatMobile = isMobile || floatingNarrow;
|
||
|
||
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: (isChatMobile || isTablet) && (!!activeSession || roomThreadActive),
|
||
allowNonMobileViewport: isTablet,
|
||
});
|
||
const tabletKeyboardOpen = isTablet && keyboardOpen;
|
||
|
||
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]);
|
||
|
||
// Key the reset on skill ids, not array identity: useDiscoveredSkillsCache
|
||
// (SWR) re-delivers content-identical lists with fresh identities (cache
|
||
// reads re-parse; revalidation notifies a new array). Resetting on identity
|
||
// alone wipes the user's keyboard highlight mid-navigation when a
|
||
// revalidation lands — only a *semantic* list change should reset it.
|
||
const filteredSkillsKey = useMemo(
|
||
() => filteredSkills.map((skill) => skill.id).join(" |