Files
fusion/packages/dashboard/app/components/ChatThinkingLevelControl.tsx
gsxdsm 8835c6cb48 FN-7908: add in-chat model/agent switcher to brain-icon popup
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>
2026-07-12 21:33:50 -07:00

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>
);
}