Separate direct-chat session groups and make message edits safely retryable.
- Render explicit Pinned and Recent sections across desktop and mobile chat lists.
- Keep inline message corrections open until edit-and-resend succeeds in Direct and Planner Chat.
- Add localized Recent labels, documentation, release metadata, and regression coverage.
Files changed:
.changeset/fn-8397-chat-pin-separation-and-edit-save.md | 7 +++
docs/dashboard-guide.md | 5 +-
packages/dashboard/app/components/ChatView.css | 10 ++++
packages/dashboard/app/components/ChatView.tsx | 31 ++++++++---
packages/dashboard/app/components/StandardChatSurface.tsx | 35 +++++++++----
packages/dashboard/app/components/TaskPlannerChatTab.tsx | 14 +++--
packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx | 46 +++++++++++++++++
packages/dashboard/app/components/__tests__/ChatView.message-edit.test.tsx | 60 ++++++++++++++++++++--
packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx | 10 ++--
packages/dashboard/app/hooks/__tests__/useChat.test.ts | 4 +-
packages/dashboard/app/hooks/useChat.ts | 13 +++--
packages/i18n/locales/{en,es,fr,ko,zh-CN,zh-TW}/app.json | 6 +++
17 files changed, 205 insertions(+), 36 deletions(-)
Fusion-Task-Id: FN-8397
Fusion-Task-Lineage: 4dbc664a-56a3-48c7-be9a-febd5f411c9e
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
4044 lines
163 KiB
TypeScript
4044 lines
163 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, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
MessageSquare,
|
||
Plus,
|
||
Search,
|
||
Trash2,
|
||
Archive,
|
||
Pencil,
|
||
ChevronLeft,
|
||
Bot,
|
||
Paperclip,
|
||
ChevronDown,
|
||
Copy,
|
||
Check,
|
||
Maximize2,
|
||
Minimize2,
|
||
X,
|
||
Hash,
|
||
Pin,
|
||
PinOff,
|
||
MoreHorizontal,
|
||
} from "lucide-react";
|
||
import { FN_AGENT_ID, useChat, type ChatMessageInfo } from "../hooks/useChat";
|
||
import { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../hooks/useChatRooms";
|
||
import { useChatUnread } from "../hooks/useChatUnread";
|
||
import { useViewportMode } from "./Header";
|
||
import { fetchSettings, updateGlobalSettings, type DiscoveredSkill } from "../api";
|
||
import { type Agent, type Settings } from "@fusion/core";
|
||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||
import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl";
|
||
import { AgentMentionPopup } from "./AgentMentionPopup";
|
||
import { AgentAvatar } from "./AgentAvatar";
|
||
import { ProviderIcon } from "./ProviderIcon";
|
||
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 { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||
import { estimateChatTokens, formatTokenCount } from "../utils/estimateChatTokens";
|
||
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
||
import { useTranslation } from "react-i18next";
|
||
import type { TFunction } from "i18next";
|
||
import { ViewHeader } from "./ViewHeader";
|
||
import {
|
||
StandardChatActionButton,
|
||
StandardChatMessageItem,
|
||
StandardStreamingMessage,
|
||
formatModelTag,
|
||
} from "./StandardChatSurface";
|
||
import { CHAT_COMMANDS, matchChatCommand, filterChatCommands, getSlashTriggerMatch, type ChatCommand } from "./chat-commands";
|
||
|
||
/**
|
||
* Optional task-bound context that enables the "/" command registry (e.g.
|
||
* `/steer`) in a ChatView instance. When omitted (the default for the
|
||
* general, non-task-bound Chat surface), the command registry contributes
|
||
* nothing to the "/" menu and dispatch-on-submit is a no-op — skills
|
||
* autocomplete behaves exactly as before.
|
||
*/
|
||
export interface ChatCommandContext {
|
||
taskId: string;
|
||
projectId?: string;
|
||
/** Whether the bound task currently has a running/active agent. `/steer` is only dispatchable when true. */
|
||
agentRunning: boolean;
|
||
}
|
||
|
||
/**
|
||
* A single entry in the generalized "/" menu — either a registered command
|
||
* (e.g. `/steer`) or a discovered skill. Both kinds share one highlighted
|
||
* index / keyboard-nav path; only their selection behavior differs (a
|
||
* command is inserted as trigger text or dispatched later on submit, a
|
||
* skill is always inserted as a `/skill:<name>` text token).
|
||
*/
|
||
export type SkillMenuEntry =
|
||
| { kind: "command"; command: ChatCommand; disabled: boolean }
|
||
| { kind: "skill"; skill: DiscoveredSkill };
|
||
|
||
export interface ChatViewProps {
|
||
projectId?: string;
|
||
addToast: (msg: string, type?: "success" | "error" | "warning") => void;
|
||
experimentalFeatures?: Record<string, boolean>;
|
||
floating?: boolean;
|
||
/** Enables the "/" command registry (e.g. `/steer`) for this composer instance. See {@link ChatCommandContext}. */
|
||
chatCommandContext?: ChatCommandContext;
|
||
/*
|
||
FNXC:RightDockChat 2026-06-27-23:12:
|
||
The right dock can host ChatView in a 360px sidebar while the browser viewport remains desktop-sized. Let dock callers force the same narrow list/detail layout used by mobile/resized floating chat without passing floating chrome callbacks.
|
||
*/
|
||
compactLayout?: boolean;
|
||
onPopOut?: () => void;
|
||
onMaximize?: () => void;
|
||
onMinimize?: () => void;
|
||
onClose?: () => void;
|
||
/** Optional external composer seed; paired with a nonce so repeated opens reseed intentionally. */
|
||
initialComposerDraft?: string;
|
||
initialComposerDraftNonce?: number;
|
||
}
|
||
|
||
// 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;
|
||
const CHAT_CONTEXT_MENU_FALLBACK_WIDTH_PX = 200;
|
||
const CHAT_CONTEXT_MENU_VIEWPORT_MARGIN_PX = 8;
|
||
|
||
/** Returns an issue or pull-request URL as a standalone composer line. */
|
||
export function buildIssueChatPrefill(url: string): string {
|
||
const trimmedUrl = url.trim();
|
||
return trimmedUrl ? `${trimmedUrl}\n\n` : "";
|
||
}
|
||
|
||
export function resolveChatContextMenuPosition(
|
||
anchorX: number,
|
||
anchorY: number,
|
||
anchorRight: boolean,
|
||
menuWidth: number,
|
||
menuHeight: number,
|
||
viewportWidth: number,
|
||
viewportHeight: number,
|
||
) {
|
||
const maximumLeft = Math.max(CHAT_CONTEXT_MENU_VIEWPORT_MARGIN_PX, viewportWidth - menuWidth - CHAT_CONTEXT_MENU_VIEWPORT_MARGIN_PX);
|
||
const maximumTop = Math.max(CHAT_CONTEXT_MENU_VIEWPORT_MARGIN_PX, viewportHeight - menuHeight - CHAT_CONTEXT_MENU_VIEWPORT_MARGIN_PX);
|
||
const proposedLeft = anchorRight ? anchorX - menuWidth : anchorX;
|
||
return {
|
||
x: Math.min(Math.max(CHAT_CONTEXT_MENU_VIEWPORT_MARGIN_PX, proposedLeft), maximumLeft),
|
||
y: Math.min(Math.max(CHAT_CONTEXT_MENU_VIEWPORT_MARGIN_PX, anchorY), maximumTop),
|
||
};
|
||
}
|
||
/** 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();
|
||
}
|
||
|
||
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 findSubmittedQuestionAnswer(messages: ChatMessageInfo[], messageIndex: number): string | undefined {
|
||
return messages.slice(messageIndex + 1).find((message) => message.role === "user")?.content;
|
||
}
|
||
|
||
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",
|
||
];
|
||
|
||
/**
|
||
* ChatView's local name for the shared slash-trigger matcher used by both
|
||
* skill autocomplete and the command registry (see chat-commands.ts's
|
||
* `getSlashTriggerMatch` doc comment: this alias exists so there is exactly
|
||
* one implementation of the trigger regex in the dashboard package).
|
||
*/
|
||
const getSkillTriggerMatch = getSlashTriggerMatch;
|
||
|
||
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;
|
||
defaultKind?: "model" | "agent";
|
||
defaultAgentId?: string;
|
||
defaultThinkingLevel?: string;
|
||
defaultSelectedThinkingLevel?: string;
|
||
onClose: () => void;
|
||
onCreate: (input: { agentId: string; modelProvider?: string; modelId?: string; thinkingLevel?: string }) => void;
|
||
}
|
||
|
||
function NewChatDialog({ projectId, defaultModel, defaultKind, defaultAgentId, defaultThinkingLevel, defaultSelectedThinkingLevel, onClose, onCreate }: NewChatDialogProps) {
|
||
const { t } = useTranslation("app");
|
||
const [chatMode, setChatMode] = useState<"agent" | "model">(defaultKind ?? "agent");
|
||
const { agents, loading: agentsLoading } = useAgentsMapCache(projectId);
|
||
const [selectedAgentId, setSelectedAgentId] = useState<string>(defaultAgentId ?? "");
|
||
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);
|
||
/*
|
||
* FNXC:Chat-ThinkingLevel 2026-07-10-00:00:
|
||
* New model-mode chats expose the shared inline thinking selector; an empty value means Default and is omitted from the create-session payload so the backend resolves project/global reasoning effort.
|
||
*/
|
||
const [thinkingLevel, setThinkingLevel] = useState<string>(defaultSelectedThinkingLevel ?? "");
|
||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>(cachedFavoriteProviders);
|
||
const [favoriteModels, setFavoriteModels] = useState<string[]>(cachedFavoriteModels);
|
||
|
||
useEffect(() => {
|
||
setFavoriteProviders(cachedFavoriteProviders);
|
||
}, [cachedFavoriteProviders]);
|
||
|
||
useEffect(() => {
|
||
setFavoriteModels(cachedFavoriteModels);
|
||
}, [cachedFavoriteModels]);
|
||
|
||
useEffect(() => {
|
||
if (defaultKind) {
|
||
setChatMode(defaultKind);
|
||
}
|
||
}, [defaultKind]);
|
||
|
||
useEffect(() => {
|
||
if (!defaultAgentId) {
|
||
return;
|
||
}
|
||
setSelectedAgentId((current) => current || defaultAgentId);
|
||
}, [defaultAgentId]);
|
||
|
||
useEffect(() => {
|
||
if (!defaultModelValue) {
|
||
return;
|
||
}
|
||
setSelectedModel((current) => current || defaultModelValue);
|
||
}, [defaultModelValue]);
|
||
|
||
useEffect(() => {
|
||
if (!defaultSelectedThinkingLevel) {
|
||
return;
|
||
}
|
||
setThinkingLevel((current) => current || defaultSelectedThinkingLevel);
|
||
}, [defaultSelectedThinkingLevel]);
|
||
|
||
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, thinkingLevel: thinkingLevel || undefined });
|
||
};
|
||
|
||
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}
|
||
showThinkingLevel
|
||
thinkingLevel={thinkingLevel}
|
||
onThinkingLevelChange={setThinkingLevel}
|
||
defaultThinkingLevel={defaultThinkingLevel ?? "off"}
|
||
/>
|
||
)}
|
||
</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>;
|
||
}
|
||
|
||
export function ChatView({ projectId, addToast, floating = false, compactLayout = false, onPopOut, onMaximize, onMinimize, onClose, chatCommandContext, initialComposerDraft, initialComposerDraftNonce }: 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 [chatSettings, setChatSettings] = useState<Settings | null>(null);
|
||
/*
|
||
FNXC:Chat-ThinkingLevel 2026-07-12-20:05:
|
||
The chat Default thinking-level labels must surface the same resolved project/global default every dashboard model picker reads from Settings (`defaultThinkingLevel ?? "off"`) instead of hardcoding `off`.
|
||
This fetch only corrects labels in NewChatDialog and ChatThinkingLevelControl; send-time resolution remains centralized in `resolveExecutorThinkingLevel` in dashboard chat.ts.
|
||
*/
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setChatSettings(null);
|
||
fetchSettings(projectId)
|
||
.then((settings) => {
|
||
if (!cancelled) {
|
||
setChatSettings(settings);
|
||
}
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) {
|
||
setChatSettings(null);
|
||
}
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [projectId]);
|
||
const resolvedDefaultThinkingLevel = chatSettings?.defaultThinkingLevel ?? "off";
|
||
const chatDefaultTarget = useMemo(() => {
|
||
/*
|
||
FNXC:ChatModels 2026-07-12-20:45:
|
||
New Chat has one project-scoped default target resolver shared by every affordance. A complete agent default wins only when kind=agent; a complete model pair wins only when kind=model; incomplete always-default settings fall back to the picker instead of creating an unroutable session.
|
||
*/
|
||
if (chatSettings?.chatDefaultKind === "agent" && chatSettings.chatDefaultAgentId) {
|
||
return {
|
||
kind: "agent" as const,
|
||
agentId: chatSettings.chatDefaultAgentId,
|
||
};
|
||
}
|
||
if (chatSettings?.chatDefaultKind === "model" && chatSettings.chatDefaultModelProvider && chatSettings.chatDefaultModelId) {
|
||
return {
|
||
kind: "model" as const,
|
||
modelProvider: chatSettings.chatDefaultModelProvider,
|
||
modelId: chatSettings.chatDefaultModelId,
|
||
thinkingLevel: chatSettings.chatDefaultThinkingLevel,
|
||
};
|
||
}
|
||
return null;
|
||
}, [chatSettings]);
|
||
|
||
const {
|
||
activeSession,
|
||
sessionsLoading,
|
||
messages,
|
||
messagesLoading,
|
||
isStreaming,
|
||
streamingText,
|
||
streamingThinking,
|
||
streamingToolCalls,
|
||
selectSession,
|
||
createSession,
|
||
archiveSession,
|
||
renameSession,
|
||
pinSession,
|
||
pinnedCount,
|
||
setSessionModel,
|
||
setSessionThinkingLevel,
|
||
deleteSession,
|
||
sendMessage,
|
||
editMessageAndResend,
|
||
stopStreaming,
|
||
pendingMessages,
|
||
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; anchorX: number; anchorY: number; anchorRight: boolean; x: number; y: number } | null>(null);
|
||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||
/*
|
||
FNXC:ChatSidebar 2026-07-17-00:12:
|
||
FN-8191 positions each conversation-row action menu from its rendered dimensions, rather than a width derived from the default theme. This keeps the trigger edge aligned under alternate spacing themes and clamps all four actions inside both viewport axes.
|
||
*/
|
||
const openSessionMenu = (
|
||
sessionId: string,
|
||
anchorX: number,
|
||
anchorY: number,
|
||
options?: { anchorRight?: boolean },
|
||
) => {
|
||
if (typeof window === "undefined") return;
|
||
|
||
setContextMenu({
|
||
sessionId,
|
||
anchorX,
|
||
anchorY,
|
||
anchorRight: options?.anchorRight ?? false,
|
||
x: anchorX,
|
||
y: anchorY,
|
||
});
|
||
};
|
||
|
||
useLayoutEffect(() => {
|
||
if (!contextMenu || !contextMenuRef.current || typeof window === "undefined") return;
|
||
|
||
const menu = contextMenuRef.current;
|
||
const bounds = menu.getBoundingClientRect();
|
||
/* FNXC:ChatSidebar 2026-07-17-00:12: JSDOM has no layout, so its non-visual test fallback preserves the default-theme menu width while browsers always use rendered dimensions. */
|
||
const width = bounds.width || menu.offsetWidth || CHAT_CONTEXT_MENU_FALLBACK_WIDTH_PX;
|
||
const height = bounds.height || menu.offsetHeight;
|
||
const position = resolveChatContextMenuPosition(
|
||
contextMenu.anchorX,
|
||
contextMenu.anchorY,
|
||
contextMenu.anchorRight,
|
||
width,
|
||
height,
|
||
window.innerWidth,
|
||
window.innerHeight,
|
||
);
|
||
|
||
if (position.x !== contextMenu.x || position.y !== contextMenu.y) {
|
||
setContextMenu({ ...contextMenu, ...position });
|
||
}
|
||
}, [contextMenu]);
|
||
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 { models, favoriteProviders, favoriteModels, defaultProvider, defaultModelId } = useModelsCache();
|
||
const defaultModel = useMemo<DefaultModelSelection>(() => ({ provider: defaultProvider, modelId: defaultModelId }), [defaultModelId, defaultProvider]);
|
||
const dialogDefaultModel = useMemo<DefaultModelSelection>(() => {
|
||
if (chatDefaultTarget?.kind === "model") {
|
||
return { provider: chatDefaultTarget.modelProvider, modelId: chatDefaultTarget.modelId };
|
||
}
|
||
return defaultModel;
|
||
}, [chatDefaultTarget, defaultModel]);
|
||
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);
|
||
// FNXC:ChatRenderToggle 2026-07-04-00:00: The markdown/plain eye toggle
|
||
// (showAllAsPlain / toggleAllAsPlain) was removed per FN-7541. Chat always
|
||
// renders Markdown now; forcePlain is hardcoded to false everywhere below.
|
||
// 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);
|
||
const clippedMessageFrameRef = useRef<number | null>(null);
|
||
const [topClippedMessageIds, setTopClippedMessageIds] = useState<Set<string>>(() => new Set());
|
||
// 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 appliedComposerDraftNonceRef = useRef<number | undefined>(undefined);
|
||
const focusComposerAfterPrefillRef = useRef(false);
|
||
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 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 || compactLayout;
|
||
|
||
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);
|
||
const skipNextDraftRestoreRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
if (activeDraftKey === lastDraftKeyRef.current) {
|
||
return;
|
||
}
|
||
|
||
lastDraftKeyRef.current = activeDraftKey;
|
||
if (skipNextDraftRestoreRef.current) {
|
||
skipNextDraftRestoreRef.current = false;
|
||
return;
|
||
}
|
||
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]);
|
||
|
||
// Commands only contribute to the "/" menu when this ChatView instance is
|
||
// bound to a task (chatCommandContext provided) — the general, non-task-bound
|
||
// Chat surface never shows/dispatches them, so its skill-only behavior is unchanged.
|
||
const filteredCommands = useMemo(() => {
|
||
if (!chatCommandContext) return [] as ChatCommand[];
|
||
return filterChatCommands(skillFilter, CHAT_COMMANDS);
|
||
}, [chatCommandContext, skillFilter]);
|
||
|
||
const skillMenuEntries = useMemo<SkillMenuEntry[]>(() => {
|
||
const commandEntries: SkillMenuEntry[] = filteredCommands.map((command) => ({
|
||
kind: "command",
|
||
command,
|
||
disabled: !chatCommandContext?.agentRunning,
|
||
}));
|
||
const skillEntries: SkillMenuEntry[] = filteredSkills.map((skill) => ({ kind: "skill", skill }));
|
||
return [...commandEntries, ...skillEntries];
|
||
}, [filteredCommands, filteredSkills, chatCommandContext]);
|
||
|
||
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(" |