Extend the chat brain-icon popup and its backing session PATCH route so an active Direct chat's model or agent can be switched mid-conversation instead of only being set at creation time. - Add a Model/Agent section to ChatThinkingLevelControl (the brain-icon popup) for picking a model provider/model or retargeting to a real agent without leaving the chat. - Extend PATCH /api/chat/sessions/:id to accept modelProvider/modelId (as a validated pair via the existing validateModelPair helper) and agentId, forwarding only the keys present in the body so omitted fields leave the session's stored target untouched. - Add chat-store updateSession support for the agentId clause alongside the existing model/thinkingLevel fields, and a useChat.setSessionModel hook for the dashboard to call the new PATCH capability. - Update i18n locale strings (en/es/fr/ko/zh-CN/zh-TW) and dashboard-guide.md docs for the new switcher UI. - Add unit/integration test coverage across chat-store, chat-manager, chat-routes, useChat, ChatThinkingLevelControl, and ChatView for the new model/agent switch behavior. - Add changeset fn-7908-chat-model-agent-switcher.md (minor, @runfusion/fusion). Files changed: .changeset/fn-7908-chat-model-agent-switcher.md | 7 + docs/dashboard-guide.md | 3 +- packages/core/src/__tests__/chat-store.test.ts | 21 ++ packages/core/src/chat-store.ts | 8 + packages/core/src/chat-types.ts | 2 + packages/dashboard/app/api/legacy.ts | 11 +- .../app/components/ChatThinkingLevelControl.tsx | 219 ++++++++++++++++++--- packages/dashboard/app/components/ChatView.css | 135 ++++++++++++- packages/dashboard/app/components/ChatView.tsx | 23 ++- .../__tests__/ChatThinkingLevelControl.test.tsx | 109 +++++++++- .../__tests__/ChatView.thinking-level.test.tsx | 67 ++++++- .../dashboard/app/hooks/__tests__/useChat.test.ts | 166 +++++++++++++++- packages/dashboard/app/hooks/useChat.ts | 56 ++++++ .../dashboard/src/__tests__/chat-manager.test.ts | 38 ++++ .../dashboard/src/__tests__/chat-routes.test.ts | 117 ++++++++++- .../dashboard/src/routes/register-chat-routes.ts | 48 ++++- packages/i18n/locales/en/app.json | 8 +- packages/i18n/locales/es/app.json | 8 +- packages/i18n/locales/fr/app.json | 8 +- packages/i18n/locales/ko/app.json | 8 +- packages/i18n/locales/zh-CN/app.json | 8 +- packages/i18n/locales/zh-TW/app.json | 8 +- 22 files changed, 1007 insertions(+), 71 deletions(-) Fusion-Task-Id: FN-7908 Fusion-Task-Lineage: b1104865-9b0c-4d77-973e-89152fe245e0 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
294 lines
12 KiB
TypeScript
294 lines
12 KiB
TypeScript
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<void>;
|
|
/** 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<void>;
|
|
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<TargetMode>(() => (agentId && agentId !== FN_AGENT_ID ? "agent" : "model"));
|
|
const rootRef = useRef<HTMLDivElement | null>(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) => {
|
|
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
|
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<HTMLButtonElement>) => {
|
|
if (event.key === "Escape") {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleOptionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, 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<HTMLButtonElement>, nextAgentId: string) => {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
chooseAgent(nextAgentId);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="chat-thinking-level-root" ref={rootRef}>
|
|
<button
|
|
type="button"
|
|
className={`btn-icon chat-thinking-btn${isActive ? " chat-thinking-btn--active" : ""}`}
|
|
data-testid="chat-thinking-btn"
|
|
aria-haspopup="dialog"
|
|
aria-expanded={open}
|
|
aria-controls={listboxId}
|
|
aria-label={t("chat.thinkingLevelButton", "Thinking level")}
|
|
title={t("chat.thinkingLevelButton", "Thinking level")}
|
|
disabled={disabled}
|
|
onClick={() => setOpen((value) => !value)}
|
|
onKeyDown={handleTriggerKeyDown}
|
|
>
|
|
<Brain size={16} />
|
|
</button>
|
|
|
|
{open ? (
|
|
<div className="chat-thinking-popover" role="presentation" data-testid="chat-thinking-popover">
|
|
<section className="chat-thinking-target-section" aria-label={t("chat.modelAgentSection", "Model / Agent")}>
|
|
<div className="chat-thinking-section-title">{t("chat.modelAgentSection", "Model / Agent")}</div>
|
|
<div className="chat-thinking-mode-toggle" data-testid="chat-thinking-mode-toggle">
|
|
<button
|
|
type="button"
|
|
className={`chat-thinking-mode-btn${targetMode === "model" ? " chat-thinking-mode-btn--active" : ""}`}
|
|
data-testid="chat-thinking-mode-model"
|
|
onClick={() => setTargetMode("model")}
|
|
>
|
|
{t("chat.newChatModeModel", "Model")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`chat-thinking-mode-btn${targetMode === "agent" ? " chat-thinking-mode-btn--active" : ""}`}
|
|
data-testid="chat-thinking-mode-agent"
|
|
onClick={() => setTargetMode("agent")}
|
|
>
|
|
{t("chat.newChatModeAgent", "Agent")}
|
|
</button>
|
|
</div>
|
|
|
|
{targetMode === "model" ? (
|
|
<div className="chat-thinking-model-picker" data-testid="chat-thinking-model-picker">
|
|
<CustomModelDropdown
|
|
models={models}
|
|
value={currentModelValue}
|
|
onChange={chooseModel}
|
|
label={t("chat.newChatModeModel", "Model")}
|
|
placeholder={t("chat.selectModel", "Select a model")}
|
|
disabled={!onChangeModel || models.length === 0}
|
|
favoriteProviders={favoriteProviders}
|
|
favoriteModels={favoriteModels}
|
|
menuWidth="readable"
|
|
/>
|
|
{models.length === 0 ? (
|
|
<div className="chat-thinking-empty" data-testid="chat-thinking-model-empty">
|
|
{t("chat.noModelsAvailable", "No models available")}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<div className="chat-thinking-agent-list" data-testid="chat-thinking-agent-list">
|
|
{agents.length === 0 ? (
|
|
<div className="chat-thinking-empty" data-testid="chat-thinking-agent-empty">
|
|
{t("chat.noAgentsAvailable", "No agents available")}
|
|
</div>
|
|
) : (
|
|
agents.map((agent) => {
|
|
const selected = selectedAgentId === agent.id;
|
|
return (
|
|
<button
|
|
key={agent.id}
|
|
type="button"
|
|
className={`chat-thinking-agent-item${selected ? " chat-thinking-agent-item--selected" : ""}`}
|
|
data-testid={`chat-thinking-agent-${agent.id}`}
|
|
aria-pressed={selected}
|
|
disabled={!onChangeModel}
|
|
onClick={() => chooseAgent(agent.id)}
|
|
onKeyDown={(event) => handleAgentKeyDown(event, agent.id)}
|
|
>
|
|
<Bot size={16} />
|
|
<span className="chat-thinking-agent-name">{agent.name || agent.id}</span>
|
|
{agent.role ? <span className="chat-thinking-agent-role">{agent.role}</span> : null}
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
)}
|
|
{selectedAgent ? (
|
|
<div className="chat-thinking-current-target" data-testid="chat-thinking-current-agent">
|
|
{t("chat.currentAgentTarget", "Current agent: {{name}}", { name: selectedAgent.name || selectedAgent.id })}
|
|
</div>
|
|
) : currentModelValue ? (
|
|
<div className="chat-thinking-current-target" data-testid="chat-thinking-current-model">
|
|
{t("chat.currentModelTarget", "Current model: {{model}}", { model: currentModelValue })}
|
|
</div>
|
|
) : (
|
|
<div className="chat-thinking-current-target" data-testid="chat-thinking-current-default">
|
|
{t("chat.currentDefaultTarget", "Using the default chat target")}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="chat-thinking-level-section" aria-label={t("chat.thinkingLevelButton", "Thinking level")}>
|
|
<div className="chat-thinking-section-title">{t("chat.thinkingLevelSection", "Thinking level")}</div>
|
|
<div
|
|
id={listboxId}
|
|
className="chat-thinking-popover-list"
|
|
role="listbox"
|
|
aria-label={t("chat.thinkingLevelButton", "Thinking level")}
|
|
>
|
|
{THINKING_LEVEL_OPTIONS.map((value) => {
|
|
const selected = normalizedLevel === value;
|
|
return (
|
|
<button
|
|
key={value || "default"}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={selected}
|
|
className={`chat-thinking-popover-option${selected ? " active" : ""}`}
|
|
data-testid={`chat-thinking-option-${value || "default"}`}
|
|
onClick={() => chooseLevel(value)}
|
|
onKeyDown={(event) => handleOptionKeyDown(event, value)}
|
|
>
|
|
{optionLabel(value)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|