import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { useTranslation } from "react-i18next"; import { Bot, Brain } from "lucide-react"; import { THINKING_LEVELS } from "@fusion/core"; import { CustomModelDropdown } from "./CustomModelDropdown"; import type { ModelInfo } from "../api"; import { FN_AGENT_ID } from "../hooks/useChat"; /* FNXC:Chat-ThinkingLevel 2026-07-12-19:30: FN-7775 only let a user pick a direct chat session's thinking (reasoning-effort) level once, at session creation, via the New Chat dialog's model-mode picker (CustomModelDropdown's inline selector). FN-7898 closes that gap with a small `Brain`-icon trigger next to the composer's attach button that opens a popup listing the six THINKING_LEVELS plus a "Default" (clear/inherit) option; selecting one persists immediately via PATCH /api/chat/sessions/:id and takes effect on the session's next send. This mirrors ThemeDropdown.tsx's small-popover interaction pattern (rootRef + pointerdown outside-close, Escape, aria-haspopup listbox) and reuses CustomModelDropdown's exact i18n keys for level labels and the default entry, rather than introducing a parallel thinking-level list. FNXC:Chat-ThinkingLevel 2026-07-12-20:08: The Default entry must describe the resolved project/global default supplied by ChatView, while omitted props preserve the legacy isolated fallback label `Default (off)`. FNXC:Chat-ModelSwitch 2026-07-12-00:00: The same brain-icon popup now owns active direct-session targeting too: model-loop sessions can switch provider/model via CustomModelDropdown, and agent sessions can switch to a real agent from the existing list. Selecting either closes the popup and persists immediately through useChat.setSessionModel, while CLI and room composers stay gated in ChatView. */ export interface ChatThinkingLevelControlAgent { id: string; name: string; role?: string; } export interface ChatThinkingLevelControlProps { /** Session's current thinkingLevel; null/undefined/empty means "inherit default". */ level: string | null | undefined; /** Called with the newly selected level ("" for the Default/clear option). */ onChange: (level: string) => void | Promise; /** Resolved project/global default used only for the Default/clear label. */ defaultThinkingLevel?: string; models?: ModelInfo[]; favoriteProviders?: string[]; favoriteModels?: string[]; agents?: ChatThinkingLevelControlAgent[]; agentId?: string | null; modelProvider?: string | null; modelId?: string | null; onChangeModel?: (selection: { agentId?: string; modelProvider?: string | null; modelId?: string | null }) => void | Promise; disabled?: boolean; } const THINKING_LEVEL_OPTIONS = ["", ...THINKING_LEVELS] as const; type TargetMode = "model" | "agent"; export function ChatThinkingLevelControl({ level, onChange, defaultThinkingLevel = "off", models = [], favoriteProviders = [], favoriteModels = [], agents = [], agentId, modelProvider, modelId, onChangeModel, disabled = false, }: ChatThinkingLevelControlProps) { const { t } = useTranslation("app"); const [open, setOpen] = useState(false); const [targetMode, setTargetMode] = useState(() => (agentId && agentId !== FN_AGENT_ID ? "agent" : "model")); const rootRef = useRef(null); const normalizedLevel = level ?? ""; const currentModelValue = modelProvider && modelId ? `${modelProvider}/${modelId}` : ""; const selectedAgentId = agentId && agentId !== FN_AGENT_ID ? agentId : ""; const isActive = normalizedLevel !== "" || Boolean(currentModelValue) || Boolean(selectedAgentId); const listboxId = "chat-thinking-level-listbox"; useEffect(() => { if (!open) return; const handlePointerDown = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Node)) return; /* FNXC:Chat-ModelSwitch 2026-07-12-22:35: FN-7916: CustomModelDropdown renders its option list in a document.body portal outside rootRef. Treat that portaled menu as inside this popup so tablet/touch pointerdown does not dismiss the brain popup before the option onClick can persist the model selection. */ const clickedInsideRoot = rootRef.current?.contains(target); const clickedInsidePortaledModelMenu = target instanceof Element && Boolean(target.closest(".model-combobox-dropdown--portal")); if (!clickedInsideRoot && !clickedInsidePortaledModelMenu) { setOpen(false); } }; document.addEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown); }, [open]); // Close the popup whenever the underlying level or target changes out from under us // (e.g. the active session switched) so it never leaks open across a // session switch showing the previous session's options. useEffect(() => { setOpen(false); setTargetMode(selectedAgentId ? "agent" : "model"); }, [normalizedLevel, selectedAgentId, currentModelValue]); const selectedAgent = useMemo( () => agents.find((agent) => agent.id === selectedAgentId), [agents, selectedAgentId], ); const optionLabel = (value: string): string => { if (value === "") { return t("modelSelection.thinkingDefault", "Default ({{level}})", { level: defaultThinkingLevel ?? "off" }); } return t(`models.options.${value}`, value === "xhigh" ? "Very High" : value.charAt(0).toUpperCase() + value.slice(1)); }; const chooseLevel = (value: string) => { setOpen(false); void onChange(value); }; const chooseModel = (value: string) => { const slashIdx = value.indexOf("/"); if (slashIdx <= 0) return; setOpen(false); void onChangeModel?.({ modelProvider: value.slice(0, slashIdx), modelId: value.slice(slashIdx + 1) }); }; const chooseAgent = (nextAgentId: string) => { if (!nextAgentId) return; setOpen(false); void onChangeModel?.({ agentId: nextAgentId }); }; const handleTriggerKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setOpen(false); } }; const handleOptionKeyDown = (event: KeyboardEvent, value: string) => { if (event.key === "Escape") { event.preventDefault(); setOpen(false); return; } if (event.key === "Enter" || event.key === " ") { event.preventDefault(); chooseLevel(value); } }; const handleAgentKeyDown = (event: KeyboardEvent, nextAgentId: string) => { if (event.key === "Escape") { event.preventDefault(); setOpen(false); return; } if (event.key === "Enter" || event.key === " ") { event.preventDefault(); chooseAgent(nextAgentId); } }; return (
{open ? (
{t("chat.modelAgentSection", "Model / Agent")}
{targetMode === "model" ? (
{models.length === 0 ? (
{t("chat.noModelsAvailable", "No models available")}
) : null}
) : (
{agents.length === 0 ? (
{t("chat.noAgentsAvailable", "No agents available")}
) : ( agents.map((agent) => { const selected = selectedAgentId === agent.id; return ( ); }) )}
)} {selectedAgent ? (
{t("chat.currentAgentTarget", "Current agent: {{name}}", { name: selectedAgent.name || selectedAgent.id })}
) : currentModelValue ? (
{t("chat.currentModelTarget", "Current model: {{model}}", { model: currentModelValue })}
) : (
{t("chat.currentDefaultTarget", "Using the default chat target")}
)}
{t("chat.thinkingLevelSection", "Thinking level")}
{THINKING_LEVEL_OPTIONS.map((value) => { const selected = normalizedLevel === value; return ( ); })}
) : null}
); }