Keep the chat sidebar from crowding tablet chat while the software keyboard is open. - extend mobile keyboard detection so tablet chat can react to visual viewport keyboard changes - hide the chat sidebar and resize handle while the tablet keyboard is open, then restore prior visibility when it closes - add regression coverage for tablet keyboard behavior plus desktop and mobile guardrails Files changed: packages/dashboard/app/components/ChatView.tsx | 39 +++++- packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx | 139 ++++++++++++++++++++- packages/dashboard/app/hooks/useMobileKeyboard.ts | 7 +- 3 files changed, 171 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-6178 Fusion-Task-Lineage: 7acdb76a-135f-4b9c-ab50-708eec03b2c6
3607 lines
136 KiB
TypeScript
3607 lines
136 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 { 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 { 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";
|
||
import { useTranslation } from "react-i18next";
|
||
import type { TFunction } from "i18next";
|
||
|
||
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;
|
||
/** 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): "auto" | "hidden" {
|
||
return scrollHeight > CHAT_INPUT_MAX_HEIGHT_PX ? "auto" : "hidden";
|
||
}
|
||
|
||
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, 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): 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
|
||
? `${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;
|
||
}
|
||
|
||
// 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 { 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)}
|
||
{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, experimentalFeatures }: 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,
|
||
deleteSession,
|
||
sendMessage,
|
||
stopStreaming,
|
||
pendingMessage,
|
||
clearPendingMessage,
|
||
loadMoreMessages,
|
||
hasMoreMessages,
|
||
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 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);
|
||
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);
|
||
const tabletKeyboardSidebarVisibilityRef = useRef<boolean | null>(null);
|
||
const mode = useViewportMode();
|
||
const isMobile = mode === "mobile";
|
||
const isTablet = mode === "tablet";
|
||
|
||
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 || isTablet) && (!!activeSession || roomThreadActive),
|
||
allowNonMobileViewport: isTablet,
|
||
});
|
||
const tabletKeyboardOpen = isTablet && keyboardOpen;
|
||
|
||
useEffect(() => {
|
||
if (!isTablet) {
|
||
tabletKeyboardSidebarVisibilityRef.current = null;
|
||
return;
|
||
}
|
||
|
||
if (keyboardOpen) {
|
||
setSidebarVisible((currentSidebarVisible) => {
|
||
if (tabletKeyboardSidebarVisibilityRef.current === null) {
|
||
tabletKeyboardSidebarVisibilityRef.current = currentSidebarVisible;
|
||
}
|
||
return currentSidebarVisible ? false : currentSidebarVisible;
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (tabletKeyboardSidebarVisibilityRef.current !== null) {
|
||
const shouldRestoreSidebar = tabletKeyboardSidebarVisibilityRef.current;
|
||
tabletKeyboardSidebarVisibilityRef.current = null;
|
||
setSidebarVisible(shouldRestoreSidebar);
|
||
}
|
||
}, [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(" |