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>
This commit is contained in:
gsxdsm
2026-07-12 21:33:50 -07:00
parent 3326984a6d
commit 8835c6cb48
22 changed files with 1007 additions and 71 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Switch an active chat's model or agent mid-conversation from the brain-icon popup.
category: feature
dev: Extends PATCH /api/chat/sessions/:id with validated modelProvider+modelId and agentId, adds chat-store updateSession agentId clause, useChat.setSessionModel, and a Model/Agent section in the brain popup (ChatThinkingLevelControl).

View File

@@ -519,7 +519,8 @@ Chat view provides project-scoped conversations with agents.
- In the New Chat dialog's **Model** mode, the model picker includes a **Thinking Level** selector. Choosing **Default** leaves the session unset so Fusion uses the project/global reasoning-effort default; choosing a concrete level stores it on that chat session and applies to model-loop replies. The Default label shows the current resolved default (for example **Default (medium)**) and falls back to **Default (off)** when no default is configured.
<!-- FNXC:Chat-ThinkingLevel 2026-07-12-19:30: FN-7898 closes the gap left by the create-time-only picker above: an existing session's reasoning-effort level can now be changed mid-conversation from the composer itself. -->
<!-- FNXC:Chat-ThinkingLevel 2026-07-12-20:13: FN-7905: chat thinking-level Default entries display the resolved project/global default in both the New Chat picker and in-chat Brain popup; choosing Default still clears the per-session override. -->
- A small **Brain**-icon button next to the composer's attach button lets you change an already-created direct chat session's thinking level mid-conversation, without starting a new chat. It opens a popup listing the six thinking levels plus **Default** (clear/inherit, labeled with the current resolved default such as **Default (medium)**); the selection persists immediately and applies starting with the session's next send. This control appears only for model-loop (non-CLI) direct sessions — it is not shown for CLI-agent-backed sessions or in Chat Rooms, neither of which support a per-session thinking level.
<!-- FNXC:Chat-ModelSwitch 2026-07-12-00:00: FN-7908 extends that same Brain popup rather than adding another composer button, so active non-CLI Direct chats can retarget to a model pair or real agent mid-conversation and the next send resolves the updated session target. -->
- A small **Brain**-icon button next to the composer's attach button lets you change an already-created direct chat session's target and thinking level mid-conversation, without starting a new chat. Its **Model / Agent** section can switch the session to another model via the shared model picker or to a real agent from the agent list; its **Thinking level** section still lists the six thinking levels plus **Default** (clear/inherit, labeled with the current resolved default such as **Default (medium)**). Each selection persists immediately and applies starting with the session's next send. This control appears only for non-CLI Direct sessions — it is not shown for CLI-agent-backed sessions or in Chat Rooms, neither of which support this per-session retargeting control.
- Full Chat and Quick Chat both consume the same streamed `/api/chat/sessions/:id/messages` response contract, and both now prefer the authoritative assistant `message` snapshot on `done` while still accumulating `text` chunks when present (so providers without incremental text streaming still render output immediately)
<!-- FNXC:ChatEmptyMessage 2026-07-10-00:00: Empty final assistant responses can be legitimate provider output (for example a Grok CLI run ending without text). Document the shared Chat/Planner Chat behavior so operators see "No message" instead of interpreting a blank bubble as a rendering failure. -->
- Final assistant messages with no text, tool calls, thinking output, attachments, or failure details render a muted **No message** placeholder instead of a blank bubble. In-progress responses still use the existing **Working…** / **Thinking…** streaming state until the run finishes.

View File

@@ -411,6 +411,27 @@ describe("ChatStore", () => {
expect(store.getSession(session.id)?.thinkingLevel).toBe("off");
});
it("updates agentId without clobbering omitted model fields", () => {
const session = createTestSession(store, {
agentId: "__fn_agent__",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
thinkingLevel: "high",
});
const updated = store.updateSession(session.id, { agentId: "agent-specialist" });
expect(updated!.agentId).toBe("agent-specialist");
expect(updated!.modelProvider).toBe("anthropic");
expect(updated!.modelId).toBe("claude-sonnet-4-5");
expect(updated!.thinkingLevel).toBe("high");
expect(store.getSession(session.id)).toMatchObject({
agentId: "agent-specialist",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
});
it("returns undefined for non-existent session", () => {
const result = store.updateSession("chat-nonexistent", { title: "Test" });
expect(result).toBeUndefined();

View File

@@ -473,6 +473,14 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
setClauses.push("modelId = ?");
params.push(input.modelId);
}
/*
* FNXC:Chat-ModelSwitch 2026-07-12-00:00:
* Existing direct chats must be able to retarget to a real agent without recreating the conversation. Keep this independent from modelProvider/modelId so omitted model keys remain untouched.
*/
if (input.agentId !== undefined) {
setClauses.push("agentId = ?");
params.push(input.agentId);
}
if (input.thinkingLevel !== undefined) {
setClauses.push("thinkingLevel = ?");
params.push(input.thinkingLevel);

View File

@@ -233,6 +233,8 @@ export interface ChatSessionUpdateInput {
modelProvider?: string | null;
/** Model ID override */
modelId?: string | null;
/** New agent target — switches the session from a model to an agent */
agentId?: string;
/** Thinking/reasoning-effort override */
thinkingLevel?: string | null;
}

View File

@@ -10303,10 +10303,17 @@ export function ensureTaskPlannerChatSession(
);
}
/** Update a chat session (title, status, thinkingLevel) */
/** Update a chat session (title, status, thinkingLevel, model, or agent target) */
export function updateChatSession(
id: string,
updates: { title?: string | null; status?: string; thinkingLevel?: string | null },
updates: {
title?: string | null;
status?: string;
modelProvider?: string | null;
modelId?: string | null;
agentId?: string;
thinkingLevel?: string | null;
},
projectId?: string,
): Promise<ChatSessionResponse> {
return api<ChatSessionResponse>(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId), {

View File

@@ -1,7 +1,10 @@
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { Brain } from "lucide-react";
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:
@@ -17,8 +20,17 @@ 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;
@@ -26,17 +38,42 @@ export interface ChatThinkingLevelControlProps {
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", disabled = false }: ChatThinkingLevelControlProps) {
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 isActive = normalizedLevel !== "";
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(() => {
@@ -50,12 +87,18 @@ export function ChatThinkingLevelControl({ level, onChange, defaultThinkingLevel
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
// Close the popup whenever the underlying level changes out from under us
// 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);
}, [normalizedLevel]);
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 === "") {
@@ -69,6 +112,19 @@ export function ChatThinkingLevelControl({ level, onChange, defaultThinkingLevel
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);
@@ -87,13 +143,25 @@ export function ChatThinkingLevelControl({ level, onChange, defaultThinkingLevel
}
};
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="listbox"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={listboxId}
aria-label={t("chat.thinkingLevelButton", "Thinking level")}
@@ -106,31 +174,118 @@ export function ChatThinkingLevelControl({ level, onChange, defaultThinkingLevel
</button>
{open ? (
<div className="chat-thinking-popover" role="presentation">
<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>
<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>

View File

@@ -1968,6 +1968,9 @@ FN-7898 adds a Brain-icon trigger next to the attach button so an active model-l
thinking (reasoning-effort) level can be changed mid-conversation. The root wrapper needs
position: relative so the popover (mirroring .chat-skill-menu's positioning/z-index) anchors to
the trigger instead of the whole .chat-input-row.
FNXC:Chat-ModelSwitch 2026-07-12-00:00:
FN-7908 keeps model/agent retargeting inside the same brain popup. The widened panel uses token-based widths and bounded scroll regions so the CustomModelDropdown trigger and agent list remain usable on desktop and mobile without adding a second composer affordance.
*/
.chat-thinking-level-root {
position: relative;
@@ -1988,8 +1991,9 @@ the trigger instead of the whole .chat-input-row.
position: absolute;
left: 0;
bottom: calc(100% + var(--space-xs));
min-width: calc((var(--space-xl) * 6) + var(--space-2xl));
max-width: calc(100vw - (var(--space-lg) * 2));
width: min(calc(var(--space-xl) * 15), calc(100vw - (var(--space-lg) * 2)));
max-height: min(calc(var(--space-xl) * 24), calc(100vh - (var(--space-xl) * 4)));
overflow-y: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
@@ -1997,11 +2001,123 @@ the trigger instead of the whole .chat-input-row.
z-index: 50;
}
.chat-thinking-target-section,
.chat-thinking-level-section {
padding: var(--space-sm);
}
.chat-thinking-target-section {
border-bottom: 1px solid var(--border);
}
.chat-thinking-section-title {
color: var(--text-secondary);
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
letter-spacing: 0.04em;
margin-bottom: var(--space-xs);
text-transform: uppercase;
}
.chat-thinking-mode-toggle {
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-sm);
padding: var(--space-2xs);
background: var(--surface-secondary);
border-radius: var(--radius-md);
}
.chat-thinking-mode-btn {
flex: 1;
border: none;
background: transparent;
color: var(--text-secondary);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8125rem;
transition: background var(--transition-fast), color var(--transition-fast);
}
.chat-thinking-mode-btn--active,
.chat-thinking-mode-btn:hover {
background: var(--surface);
color: var(--text-primary);
}
.chat-thinking-mode-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.chat-thinking-model-picker .custom-model-dropdown {
width: 100%;
}
.chat-thinking-agent-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
max-height: calc(var(--space-xl) * 8);
overflow-y: auto;
}
.chat-thinking-agent-item {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%;
border: 1px solid var(--border);
background: transparent;
color: inherit;
text-align: left;
padding: var(--space-sm);
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.chat-thinking-agent-item:hover,
.chat-thinking-agent-item--selected {
background: var(--card-hover);
border-color: var(--accent);
}
.chat-thinking-agent-item:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.chat-thinking-agent-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-thinking-agent-role,
.chat-thinking-current-target,
.chat-thinking-empty {
color: var(--text-secondary);
font-size: 0.75rem;
}
.chat-thinking-agent-role {
flex: none;
text-transform: capitalize;
}
.chat-thinking-current-target,
.chat-thinking-empty {
margin-top: var(--space-xs);
}
.chat-thinking-popover-list {
display: flex;
flex-direction: column;
padding: var(--space-xs);
max-height: calc(var(--space-xl) * 10);
max-height: calc(var(--space-xl) * 8);
overflow-y: auto;
}
@@ -2403,6 +2519,17 @@ Queued-message banners stack above the composer input with a capped scroll area,
min-height: calc(var(--space-lg) * 2.25);
}
.chat-thinking-popover {
left: 0;
width: calc(100vw - (var(--space-md) * 2));
max-height: min(calc(var(--space-xl) * 20), calc(100vh - (var(--space-xl) * 5)));
}
.chat-thinking-agent-list,
.chat-thinking-popover-list {
max-height: calc(var(--space-xl) * 7);
}
.chat-input-send,
.chat-input-stop {
min-width: var(--chat-input-control-size);

View File

@@ -19,7 +19,7 @@ import {
X,
Hash,
} from "lucide-react";
import { useChat, type ChatMessageInfo } from "../hooks/useChat";
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";
@@ -136,12 +136,6 @@ function formatRelativeTime(dateStr: string, t: TFunction<"app">): string {
return date.toLocaleDateString();
}
/**
* 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;
@@ -586,6 +580,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
createSession,
archiveSession,
renameSession,
setSessionModel,
setSessionThinkingLevel,
deleteSession,
sendMessage,
@@ -637,7 +632,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
const [createRoomOpen, setCreateRoomOpen] = useState(false);
const { agentsMap: cachedAgentsMap } = useAgentsMapCache(projectId);
const agentsMap = useMemo(() => (chatAgentsMap.size > 0 ? chatAgentsMap : cachedAgentsMap), [cachedAgentsMap, chatAgentsMap]);
const { models, defaultProvider, defaultModelId } = useModelsCache();
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") {
@@ -2775,11 +2770,23 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
<ChatThinkingLevelControl
level={activeSession?.thinkingLevel}
defaultThinkingLevel={resolvedDefaultThinkingLevel}
models={models}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
agents={Array.from(agentsMap.values())}
agentId={activeSession?.agentId}
modelProvider={activeSession?.modelProvider}
modelId={activeSession?.modelId}
onChange={(level) => {
if (activeSession) {
void setSessionThinkingLevel(activeSession.id, level);
}
}}
onChangeModel={(selection) => {
if (activeSession) {
void setSessionModel(activeSession.id, selection);
}
}}
disabled={!activeSession}
/>
)}

View File

@@ -1,8 +1,33 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { THINKING_LEVELS } from "@fusion/core";
import { FN_AGENT_ID } from "../../hooks/useChat";
import { ChatThinkingLevelControl } from "../ChatThinkingLevelControl";
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({ value, onChange, disabled }: { value: string; onChange: (value: string) => void; disabled?: boolean }) => (
<button
type="button"
data-testid="mock-model-dropdown"
data-value={value}
disabled={disabled}
onClick={() => onChange("openai/gpt-4o")}
>
{value || "Select a model"}
</button>
),
}));
const models = [
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: true, contextWindow: 128000 },
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet", reasoning: true, contextWindow: 200000 },
];
const agents = [
{ id: "agent-001", name: "Alpha", role: "executor" },
{ id: "agent-002", name: "Beta", role: "reviewer" },
];
describe("ChatThinkingLevelControl", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -17,13 +42,16 @@ describe("ChatThinkingLevelControl", () => {
expect(screen.queryByRole("listbox")).toBeNull();
});
it("opens a popup listing Default plus all six THINKING_LEVELS when the trigger is clicked", () => {
render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} />);
it("opens a popup listing Default plus all six THINKING_LEVELS and the Model / Agent section", () => {
render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} models={models} agents={agents} />);
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
const listbox = screen.getByRole("listbox");
expect(listbox).toBeDefined();
expect(screen.getByText("Model / Agent")).toBeDefined();
expect(screen.getByTestId("chat-thinking-mode-toggle")).toBeDefined();
expect(screen.getByTestId("mock-model-dropdown")).toBeDefined();
expect(screen.getByTestId("chat-thinking-option-default")).toBeDefined();
for (const level of THINKING_LEVELS) {
expect(screen.getByTestId(`chat-thinking-option-${level}`)).toBeDefined();
@@ -69,6 +97,83 @@ describe("ChatThinkingLevelControl", () => {
expect(screen.queryByRole("listbox")).toBeNull();
});
it("the Model|Agent toggle swaps controls", () => {
render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} models={models} agents={agents} />);
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
expect(screen.getByTestId("mock-model-dropdown")).toBeDefined();
fireEvent.click(screen.getByTestId("chat-thinking-mode-agent"));
expect(screen.getByTestId("chat-thinking-agent-list")).toBeDefined();
expect(screen.getByTestId("chat-thinking-agent-agent-001")).toBeDefined();
});
it("selecting a model calls onChangeModel with the provider/model pair and closes", () => {
const onChangeModel = vi.fn();
render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={onChangeModel} models={models} />);
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
fireEvent.click(screen.getByTestId("mock-model-dropdown"));
expect(onChangeModel).toHaveBeenCalledWith({ modelProvider: "openai", modelId: "gpt-4o" });
expect(screen.queryByRole("listbox")).toBeNull();
});
it("selecting an agent calls onChangeModel with agentId and closes", () => {
const onChangeModel = vi.fn();
render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={onChangeModel} agents={agents} />);
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
fireEvent.click(screen.getByTestId("chat-thinking-mode-agent"));
fireEvent.click(screen.getByTestId("chat-thinking-agent-agent-002"));
expect(onChangeModel).toHaveBeenCalledWith({ agentId: "agent-002" });
expect(screen.queryByRole("listbox")).toBeNull();
});
it("reflects the active model and active agent selection", () => {
const { rerender } = render(
<ChatThinkingLevelControl
level={null}
onChange={vi.fn()}
models={models}
agents={agents}
agentId={FN_AGENT_ID}
modelProvider="anthropic"
modelId="claude-sonnet-4-5"
/>,
);
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
expect(screen.getByTestId("mock-model-dropdown").getAttribute("data-value")).toBe("anthropic/claude-sonnet-4-5");
expect(screen.getByTestId("chat-thinking-current-model")).toHaveTextContent("anthropic/claude-sonnet-4-5");
rerender(
<ChatThinkingLevelControl
level={null}
onChange={vi.fn()}
models={models}
agents={agents}
agentId="agent-001"
/>,
);
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
expect(screen.getByTestId("chat-thinking-agent-agent-001").className).toContain("chat-thinking-agent-item--selected");
expect(screen.getByTestId("chat-thinking-current-agent")).toHaveTextContent("Alpha");
});
it("renders empty states for zero models and zero agents without crashing", () => {
render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} models={[]} agents={[]} />);
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
expect(screen.getByTestId("chat-thinking-model-empty")).toBeDefined();
expect(screen.getByTestId("mock-model-dropdown")).toBeDisabled();
fireEvent.click(screen.getByTestId("chat-thinking-mode-agent"));
expect(screen.getByTestId("chat-thinking-agent-empty")).toBeDefined();
});
it("clicking outside closes the popup without calling onChange", () => {
const onChange = vi.fn();
render(<ChatThinkingLevelControl level={null} onChange={onChange} />);

View File

@@ -28,8 +28,24 @@ vi.mock("../SessionTerminal", () => ({
vi.mock("../../hooks/useChat");
vi.mock("../../hooks/useChatRooms");
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({ defaultThinkingLevel }: { defaultThinkingLevel?: string }) => (
<div data-testid="custom-model-dropdown" data-default-thinking={defaultThinkingLevel ?? ""} />
CustomModelDropdown: ({
value,
onChange,
defaultThinkingLevel,
}: {
value?: string;
onChange?: (value: string) => void;
defaultThinkingLevel?: string;
}) => (
<button
type="button"
data-testid="custom-model-dropdown"
data-value={value ?? ""}
data-default-thinking={defaultThinkingLevel ?? ""}
onClick={() => onChange?.("openai/gpt-4o")}
>
model dropdown
</button>
),
}));
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
@@ -91,6 +107,7 @@ function chatState(overrides: Partial<UseChatReturn> = {}): UseChatReturn {
createSession: vi.fn(),
archiveSession: vi.fn(),
renameSession: vi.fn(),
setSessionModel: vi.fn(),
setSessionThinkingLevel: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
@@ -198,6 +215,37 @@ describe("ChatView thinking-level control (FN-7898)", () => {
expect(setSessionThinkingLevel).toHaveBeenCalledWith("sess-a", "high");
});
it("selecting a model in the popup calls setSessionModel(session.id, provider/model)", async () => {
const setSessionModel = vi.fn();
const session = makeSession({ id: "sess-model", cliExecutorAdapterId: null, agentId: useChatModule.FN_AGENT_ID, modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session], setSessionModel }));
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
fireEvent.click(screen.getByTestId("custom-model-dropdown"));
expect(setSessionModel).toHaveBeenCalledWith("sess-model", { modelProvider: "openai", modelId: "gpt-4o" });
});
it("selecting an agent in the popup calls setSessionModel(session.id, agentId)", async () => {
const setSessionModel = vi.fn();
const session = makeSession({ id: "sess-agent", cliExecutorAdapterId: null, agentId: useChatModule.FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o" });
const agentsMap = new Map([
["agent-001", { id: "agent-001", name: "Alpha", role: "executor" }],
["agent-002", { id: "agent-002", name: "Beta", role: "reviewer" }],
] as const);
mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session], setSessionModel, agentsMap }));
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
fireEvent.click(screen.getByTestId("chat-thinking-mode-agent"));
fireEvent.click(screen.getByTestId("chat-thinking-agent-agent-002"));
expect(setSessionModel).toHaveBeenCalledWith("sess-agent", { agentId: "agent-002" });
});
it("(b) does NOT render when the active session is CLI-backed", async () => {
const session = makeSession({ id: "sess-cli", cliExecutorAdapterId: "claude-code", cliSessionFile: "cli-native-1" });
mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session] }));
@@ -231,8 +279,8 @@ describe("ChatView thinking-level control (FN-7898)", () => {
expect(screen.queryByTestId("chat-thinking-btn")).toBeNull();
});
it("(e) updates the control's active-state class when switching from a session with a concrete thinkingLevel to one without", async () => {
const sessionWithLevel = makeSession({ id: "sess-with-level", cliExecutorAdapterId: null, thinkingLevel: "high" });
it("(e) updates the control selection and closes the popup when switching active sessions", async () => {
const sessionWithLevel = makeSession({ id: "sess-with-level", cliExecutorAdapterId: null, agentId: useChatModule.FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o", thinkingLevel: "high" });
mockUseChat.mockReturnValue(chatState({ activeSession: sessionWithLevel, sessions: [sessionWithLevel] }));
const { rerender } = await (async () => {
@@ -244,15 +292,20 @@ describe("ChatView thinking-level control (FN-7898)", () => {
})();
expect(screen.getByTestId("chat-thinking-btn").className).toContain("chat-thinking-btn--active");
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
expect(screen.getByTestId("custom-model-dropdown")).toHaveAttribute("data-value", "openai/gpt-4o");
const sessionWithoutLevel = makeSession({ id: "sess-without-level", cliExecutorAdapterId: null, thinkingLevel: null });
mockUseChat.mockReturnValue(chatState({ activeSession: sessionWithoutLevel, sessions: [sessionWithoutLevel] }));
const sessionWithoutLevel = makeSession({ id: "sess-without-level", cliExecutorAdapterId: null, agentId: "agent-001", thinkingLevel: null });
const agentsMap = new Map([["agent-001", { id: "agent-001", name: "Alpha", role: "executor" }]] as const);
mockUseChat.mockReturnValue(chatState({ activeSession: sessionWithoutLevel, sessions: [sessionWithoutLevel], agentsMap }));
await act(async () => {
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
});
expect(screen.getByTestId("chat-thinking-btn").className).not.toContain("chat-thinking-btn--active");
expect(screen.queryByTestId("chat-thinking-popover")).toBeNull();
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
expect(screen.getByTestId("chat-thinking-agent-agent-001").className).toContain("chat-thinking-agent-item--selected");
});
it("(f) renders the trigger without a layout/overflow regression at a mobile viewport", async () => {

View File

@@ -5,7 +5,7 @@
import { act, fireEvent, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useChat } from "../useChat";
import { FN_AGENT_ID, useChat } from "../useChat";
import * as apiModule from "../../api";
import { getChatPendingMessageKey } from "../chatPendingMessageStorage";
import * as swrCacheModule from "../../utils/swrCache";
@@ -1022,6 +1022,170 @@ describe("useChat", () => {
expect(addToast).toHaveBeenCalledWith("Failed to rename conversation", "error");
});
describe("setSessionModel", () => {
it("switches an active session to a model optimistically and reconciles with the server", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001", modelProvider: null, modelId: null });
const updatedSession = makeSession({
id: "session-001",
agentId: FN_AGENT_ID,
modelProvider: "openai",
modelId: "gpt-4o",
updatedAt: "2026-04-09T00:00:00.000Z",
});
const deferred = createDeferredPromise<{ session: ChatSession }>();
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockUpdateChatSession.mockReturnValueOnce(deferred.promise);
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
act(() => {
result.current.selectSession("session-001", session);
});
await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001"));
await act(async () => {
void result.current.setSessionModel("session-001", { modelProvider: "openai", modelId: "gpt-4o" });
});
expect(mockUpdateChatSession).toHaveBeenCalledWith(
"session-001",
{ agentId: FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o" },
"proj-123",
);
expect(result.current.sessions[0]).toMatchObject({ agentId: FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o" });
expect(result.current.activeSession).toMatchObject({ agentId: FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o" });
await act(async () => {
deferred.resolve({ session: updatedSession });
await deferred.promise;
});
expect(result.current.sessions[0]).toMatchObject({
agentId: FN_AGENT_ID,
modelProvider: "openai",
modelId: "gpt-4o",
updatedAt: "2026-04-09T00:00:00.000Z",
});
expect(result.current.activeSession).toMatchObject({
agentId: FN_AGENT_ID,
modelProvider: "openai",
modelId: "gpt-4o",
updatedAt: "2026-04-09T00:00:00.000Z",
});
});
it("switches an active session to an agent optimistically and clears the model pair", async () => {
const session = makeSession({ id: "session-001", agentId: FN_AGENT_ID, modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
const updatedSession = makeSession({ id: "session-001", agentId: "agent-specialist", modelProvider: null, modelId: null });
const deferred = createDeferredPromise<{ session: ChatSession }>();
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockUpdateChatSession.mockReturnValueOnce(deferred.promise);
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
act(() => {
result.current.selectSession("session-001", session);
});
await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001"));
await act(async () => {
void result.current.setSessionModel("session-001", { agentId: "agent-specialist" });
});
expect(mockUpdateChatSession).toHaveBeenCalledWith(
"session-001",
{ agentId: "agent-specialist", modelProvider: null, modelId: null },
"proj-123",
);
expect(result.current.sessions[0]).toMatchObject({ agentId: "agent-specialist", modelProvider: null, modelId: null });
expect(result.current.activeSession).toMatchObject({ agentId: "agent-specialist", modelProvider: null, modelId: null });
await act(async () => {
deferred.resolve({ session: updatedSession });
await deferred.promise;
});
});
it("rolls back sessions/activeSession and surfaces an error toast on failure", async () => {
const addToast = vi.fn();
const session = makeSession({ id: "session-001", agentId: FN_AGENT_ID, modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockUpdateChatSession.mockRejectedValueOnce(new Error("model failed"));
const { result } = renderHook(() => useChat("proj-123", addToast));
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
act(() => {
result.current.selectSession("session-001", session);
});
await waitFor(() => expect(result.current.activeSession?.modelId).toBe("claude-sonnet-4-5"));
await act(async () => {
await expect(result.current.setSessionModel("session-001", { agentId: "agent-specialist" })).rejects.toThrow("model failed");
});
expect(mockUpdateChatSession).toHaveBeenCalledWith(
"session-001",
{ agentId: "agent-specialist", modelProvider: null, modelId: null },
"proj-123",
);
expect(result.current.sessions[0]).toMatchObject({ agentId: FN_AGENT_ID, modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
expect(result.current.activeSession).toMatchObject({ agentId: FN_AGENT_ID, modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
expect(addToast).toHaveBeenCalledWith("Failed to update chat model", "error");
});
it("updates only the matching sessions entry when the session is not active", async () => {
const activeSessionSeed = makeSession({ id: "session-active", agentId: "agent-001" });
const otherSession = makeSession({ id: "session-other", agentId: "agent-002", modelProvider: null, modelId: null });
const deferred = createDeferredPromise<{ session: ChatSession }>();
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [activeSessionSeed, otherSession] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockUpdateChatSession.mockReturnValueOnce(deferred.promise);
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => expect(result.current.sessions).toHaveLength(2));
act(() => {
result.current.selectSession("session-active", activeSessionSeed);
});
await waitFor(() => expect(result.current.activeSession?.id).toBe("session-active"));
await act(async () => {
void result.current.setSessionModel("session-other", { modelProvider: "openai", modelId: "gpt-4o-mini" });
});
expect(mockUpdateChatSession).toHaveBeenCalledWith(
"session-other",
{ agentId: FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o-mini" },
"proj-123",
);
expect(result.current.sessions.find((s) => s.id === "session-other")).toMatchObject({
agentId: FN_AGENT_ID,
modelProvider: "openai",
modelId: "gpt-4o-mini",
});
expect(result.current.activeSession).toMatchObject({ id: "session-active", agentId: "agent-001" });
await act(async () => {
deferred.resolve({ session: makeSession({ ...otherSession, agentId: FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o-mini" }) });
await deferred.promise;
});
});
});
// FN-7898: setSessionThinkingLevel lets an existing session's reasoning-effort level be
// changed mid-conversation via the in-chat composer control; mirrors renameSession's
// optimistic-update-with-rollback contract.

View File

@@ -20,6 +20,11 @@ import { recordResumeEvent } from "../utils/resumeInstrumentation";
import type { Agent, ChatInFlightGenerationState, ChatMessage } from "@fusion/core";
const ACTIVE_SESSION_STORAGE_KEY = "kb-chat-active-session";
/**
* FNXC:Chat-ModelSwitch 2026-07-12-00:00:
* Model-loop direct sessions store this sentinel agent id so the UI and hook share one target-mode check instead of duplicating the literal in each composer surface.
*/
export const FN_AGENT_ID = "__fn_agent__";
const TASK_PLANNER_CHAT_AGENT_ID_PREFIX = "task-planner:";
function isTaskPlannerSession(session: ChatSessionInfo): boolean {
@@ -97,6 +102,10 @@ export interface UseChatReturn {
) => Promise<ChatSessionInfo>;
archiveSession: (id: string) => Promise<void>;
renameSession: (id: string, title: string) => Promise<void>;
setSessionModel: (
id: string,
selection: { agentId?: string; modelProvider?: string | null; modelId?: string | null },
) => Promise<void>;
/**
* FNXC:Chat-ThinkingLevel 2026-07-12-19:30:
* Change an existing (already-created) session's reasoning-effort level mid-conversation via
@@ -1026,6 +1035,52 @@ export function useChat(
[activeSession, addToast, projectId, sessions],
);
/**
* FNXC:Chat-ModelSwitch 2026-07-12-00:00:
* The brain-icon popup can retarget an active direct conversation to either a model pair or a real agent mid-conversation. Optimistically patch both session collections so the next send and visible header use the new persisted target without creating a replacement chat.
*
* FNXC:Chat-ModelSwitch 2026-07-12-21:40:
* The PATCH payload must explicitly clear the field the user is switching AWAY from (agentId
* when picking a model; modelProvider/modelId when picking an agent), not just the optimistic
* local patch. Forwarding only the caller's partial `selection` would leave a stale agentId
* (or stale modelProvider/modelId) persisted server-side, so the next send could still resolve
* against the PREVIOUS target — silently breaking the retarget this control exists for.
*/
const setSessionModel = useCallback(
async (id: string, selection: { agentId?: string; modelProvider?: string | null; modelId?: string | null }) => {
const previousSessions = sessions;
const previousActiveSession = activeSession;
const isAgentSwitch = selection.agentId !== undefined;
const optimisticPatch = isAgentSwitch
? { agentId: selection.agentId!, modelProvider: null, modelId: null }
: { agentId: FN_AGENT_ID, modelProvider: selection.modelProvider ?? null, modelId: selection.modelId ?? null };
setSessions((prev) => prev.map((session) => (session.id === id ? { ...session, ...optimisticPatch } : session)));
setActiveSession((prev) => (prev?.id === id ? { ...prev, ...optimisticPatch } : prev));
try {
const data = await updateChatSession(id, optimisticPatch, projectId);
const updatedSession = data.session;
const reconciledPatch = {
agentId: updatedSession.agentId,
modelProvider: updatedSession.modelProvider,
modelId: updatedSession.modelId,
updatedAt: updatedSession.updatedAt,
};
setSessions((prev) =>
prev.map((session) => (session.id === id ? { ...session, ...reconciledPatch } : session)),
);
setActiveSession((prev) => (prev?.id === id ? { ...prev, ...reconciledPatch } : prev));
} catch (error) {
setSessions(previousSessions);
setActiveSession(previousActiveSession);
addToast?.("Failed to update chat model", "error");
throw error;
}
},
[activeSession, addToast, projectId, sessions],
);
/**
* FNXC:Chat-ThinkingLevel 2026-07-12-19:30:
* Lets a user change an already-created direct chat session's thinking (reasoning-effort)
@@ -1751,6 +1806,7 @@ export function useChat(
createSession,
archiveSession,
renameSession,
setSessionModel,
setSessionThinkingLevel,
deleteSession,
sendMessage,

View File

@@ -246,6 +246,44 @@ describe("ChatManager.sendMessage", () => {
});
});
it("uses the updated session model pair on the next send after updateSession persistence", async () => {
let createOptions: any;
__setCreateResolvedAgentSession(async (options: any) => {
createOptions = options;
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
model: { provider: options.defaultProvider, id: options.defaultModelId },
state: { messages: [{ role: "assistant", content: "Updated model response" }] },
},
runtimeId: "openai",
wasConfigured: true,
} as any;
});
const session = {
id: "chat-001",
agentId: "__fn_agent__",
status: "active",
projectId: "project-a",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
};
mockChatStore.getSession.mockImplementation(() => session);
mockChatStore.updateSession.mockImplementation((_id, input) => {
Object.assign(session, input);
return { ...session };
});
mockChatStore.updateSession("chat-001", { modelProvider: "openai", modelId: "gpt-4o" });
const chatManager = createChatManagerWithSettings({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
await chatManager.sendMessage("chat-001", "Use the new target");
expect(mockChatStore.updateSession).toHaveBeenCalledWith("chat-001", { modelProvider: "openai", modelId: "gpt-4o" });
expect(createOptions.defaultProvider).toBe("openai");
expect(createOptions.defaultModelId).toBe("gpt-4o");
});
it("passes the chat session thinking level to model-loop session options", async () => {
let createOptions: any;
__setCreateResolvedAgentSession(async (options: any) => {

View File

@@ -1359,6 +1359,113 @@ describe("Chat API Routes", () => {
const callArgs = mockUpdateSession.mock.calls[mockUpdateSession.mock.calls.length - 1][1];
expect(callArgs).not.toHaveProperty("thinkingLevel");
});
// FN-7908: PATCH now also accepts modelProvider+modelId and agentId so the
// brain-icon popup can retarget an active Direct chat's model or switch it
// to a real agent mid-conversation, reusing the existing validateModelPair helper.
it("accepts a modelProvider+modelId pair and forwards it to updateSession", async () => {
const updatedSession = { ...sampleSession, modelProvider: "anthropic", modelId: "claude-sonnet" };
mockUpdateSession.mockReturnValue(updatedSession);
const response = await request(
app,
"PATCH",
"/api/chat/sessions/chat-abc123",
JSON.stringify({ modelProvider: "anthropic", modelId: "claude-sonnet" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect((response.body as any).session.modelProvider).toBe("anthropic");
expect(mockUpdateSession).toHaveBeenCalledWith("chat-abc123", {
modelProvider: "anthropic",
modelId: "claude-sonnet",
});
});
it("returns 400 when only modelProvider or only modelId is provided", async () => {
const response = await request(
app,
"PATCH",
"/api/chat/sessions/chat-abc123",
JSON.stringify({ modelProvider: "anthropic" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("modelProvider and modelId");
expect(mockUpdateSession).not.toHaveBeenCalled();
});
it("accepts a non-empty agentId and forwards it to updateSession", async () => {
const updatedSession = { ...sampleSession, agentId: "agent-42" };
mockUpdateSession.mockReturnValue(updatedSession);
const response = await request(
app,
"PATCH",
"/api/chat/sessions/chat-abc123",
JSON.stringify({ agentId: "agent-42" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect((response.body as any).session.agentId).toBe("agent-42");
expect(mockUpdateSession).toHaveBeenCalledWith("chat-abc123", { agentId: "agent-42" });
});
it("returns 400 for an empty-string agentId", async () => {
const response = await request(
app,
"PATCH",
"/api/chat/sessions/chat-abc123",
JSON.stringify({ agentId: "" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("agentId");
expect(mockUpdateSession).not.toHaveBeenCalled();
});
it("switching to a model clears a prior real agentId via the sentinel, and switching to an agent clears modelProvider/modelId", async () => {
const updatedSession = { ...sampleSession, agentId: "__fn_agent__", modelProvider: "anthropic", modelId: "claude-sonnet" };
mockUpdateSession.mockReturnValue(updatedSession);
const response = await request(
app,
"PATCH",
"/api/chat/sessions/chat-abc123",
JSON.stringify({ agentId: "__fn_agent__", modelProvider: "anthropic", modelId: "claude-sonnet" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect(mockUpdateSession).toHaveBeenCalledWith("chat-abc123", {
agentId: "__fn_agent__",
modelProvider: "anthropic",
modelId: "claude-sonnet",
});
});
it("omitting modelProvider/modelId/agentId leaves them untouched", async () => {
const updatedSession = { ...sampleSession, title: "New Title" };
mockUpdateSession.mockReturnValue(updatedSession);
const response = await request(
app,
"PATCH",
"/api/chat/sessions/chat-abc123",
JSON.stringify({ title: "New Title" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
const callArgs = mockUpdateSession.mock.calls[mockUpdateSession.mock.calls.length - 1][1];
expect(callArgs).not.toHaveProperty("modelProvider");
expect(callArgs).not.toHaveProperty("modelId");
expect(callArgs).not.toHaveProperty("agentId");
});
});
describe("DELETE /api/chat/sessions/:id", () => {
@@ -2357,9 +2464,9 @@ describe("multi-project chat routing", () => {
expect(response.status).toBe(200);
expect((response.body as any).success).toBe(false);
// Scoped path: getOrCreateProjectStore is called with the secondary projectId
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith(secondarySession.projectId);
// cancelGeneration was called on the scoped manager
// Scoped path shares resolveProjectChatContext with list/create and can reuse the host store when no project engine is running.
expect(mockGetOrCreateProjectStore).not.toHaveBeenCalled();
// cancelGeneration was called on the resolved manager.
expect(mockCancelGeneration).toHaveBeenCalledWith(secondarySession.id);
});
@@ -2392,8 +2499,8 @@ describe("multi-project chat routing", () => {
expect(response.status).toBe(200);
expect((response.body as any).sessions).toHaveLength(1);
// Scoped path: getOrCreateProjectStore is called for isGenerating resolution
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith(secondarySession.projectId);
// Scoped path shares resolveProjectChatContext with list/create and can reuse the host store when no project engine is running.
expect(mockGetOrCreateProjectStore).not.toHaveBeenCalled();
// isGenerating defaults to false (MockChatManager has no getGeneratingSessionIds)
expect((response.body as any).sessions[0].isGenerating).toBe(false);
});

View File

@@ -475,8 +475,9 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
/**
* PATCH /api/chat/sessions/:id
* Update a chat session (title, status, thinkingLevel).
* Body: { title?: string, status?: "active" | "archived", thinkingLevel?: string | null }
* Update a chat session (title, status, thinkingLevel, model, or agent target).
* Body: { title?: string, status?: "active" | "archived", thinkingLevel?: string | null,
* modelProvider?: string | null, modelId?: string | null, agentId?: string }
*
* FNXC:Chat-ThinkingLevel 2026-07-12-19:30:
* FN-7775 only let a user pick a session's thinking level at creation time
@@ -487,16 +488,37 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
* create-time semantics where an absent/empty thinkingLevel means
* "inherit"); omitting the key entirely leaves the session's stored
* value untouched, matching the existing title/status behavior below.
*
* FNXC:Chat-ModelSwitch 2026-07-12-20:15:
* FN-7908 extends this SAME route (rather than adding a new one) so the
* brain-icon popup introduced by FN-7898 can also retarget an active
* Direct chat's model or switch it to a real agent mid-conversation.
* modelProvider/modelId are validated as a pair via the existing
* validateModelPair helper (used elsewhere in this file for task-planner
* session creation); agentId is validated as a non-empty string. Both are
* forwarded to chatStore.updateSession only when present in the body so
* omitted keys stay untouched, matching the thinkingLevel/title/status
* pattern above.
*/
router.patch("/chat/sessions/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try {
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
const sessionId = String(req.params.id);
const { title, status, thinkingLevel: rawThinkingLevel } = req.body as {
const {
title,
status,
thinkingLevel: rawThinkingLevel,
modelProvider: rawModelProvider,
modelId: rawModelId,
agentId: rawAgentId,
} = req.body as {
title?: string;
status?: string;
thinkingLevel?: string | null;
modelProvider?: string | null;
modelId?: string | null;
agentId?: string;
};
// Validate status if provided
@@ -518,10 +540,30 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
}
}
// FNXC:Chat-ModelSwitch — modelProvider/modelId are only validated (and
// therefore only forwarded) when at least one of them is present in the
// body, so a PATCH that omits both keys entirely leaves the session's
// stored model target untouched instead of tripping the pair-mismatch
// check below.
const modelPairProvided = rawModelProvider !== undefined || rawModelId !== undefined;
const { modelProvider: normalizedModelProvider, modelId: normalizedModelId } = modelPairProvided
? validateModelPair(rawModelProvider, rawModelId)
: {};
let normalizedAgentId: string | undefined;
if (rawAgentId !== undefined) {
if (typeof rawAgentId !== "string" || rawAgentId.trim() === "") {
throw badRequest("agentId must be a non-empty string");
}
normalizedAgentId = rawAgentId.trim();
}
const session = chatStore.updateSession(sessionId, {
...(title !== undefined && { title: title?.trim() || null }),
...(status !== undefined && { status }),
...(normalizedThinkingLevel !== undefined && { thinkingLevel: normalizedThinkingLevel }),
...(modelPairProvided && { modelProvider: normalizedModelProvider ?? null, modelId: normalizedModelId ?? null }),
...(normalizedAgentId !== undefined && { agentId: normalizedAgentId }),
});
if (!session) {

View File

@@ -1398,7 +1398,13 @@
"unreadMessages": "Unread messages",
"untitledSession": "Untitled",
"workingStatus": "Working…",
"you": "You"
"you": "You",
"modelAgentSection": "Model / Agent",
"thinkingLevelSection": "Thinking level",
"noModelsAvailable": "No models available",
"currentAgentTarget": "Current agent: {{name}}",
"currentModelTarget": "Current model: {{model}}",
"currentDefaultTarget": "Using the default chat target"
},
"chatRooms": {
"error": {

View File

@@ -1388,7 +1388,13 @@
"workingStatus": "Trabajando…",
"you": "Tú",
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens",
"noMessage": ""
"noMessage": "",
"modelAgentSection": "Model / Agent",
"thinkingLevelSection": "Thinking level",
"noModelsAvailable": "No models available",
"currentAgentTarget": "Current agent: {{name}}",
"currentModelTarget": "Current model: {{model}}",
"currentDefaultTarget": "Using the default chat target"
},
"chatRooms": {
"error": {

View File

@@ -1388,7 +1388,13 @@
"workingStatus": "Traitement en cours…",
"you": "Vous",
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens",
"noMessage": ""
"noMessage": "",
"modelAgentSection": "Model / Agent",
"thinkingLevelSection": "Thinking level",
"noModelsAvailable": "No models available",
"currentAgentTarget": "Current agent: {{name}}",
"currentModelTarget": "Current model: {{model}}",
"currentDefaultTarget": "Using the default chat target"
},
"chatRooms": {
"error": {

View File

@@ -1388,7 +1388,13 @@
"workingStatus": "작업 중…",
"you": "나",
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens",
"noMessage": ""
"noMessage": "",
"modelAgentSection": "Model / Agent",
"thinkingLevelSection": "Thinking level",
"noModelsAvailable": "No models available",
"currentAgentTarget": "Current agent: {{name}}",
"currentModelTarget": "Current model: {{model}}",
"currentDefaultTarget": "Using the default chat target"
},
"chatRooms": {
"error": {

View File

@@ -1388,7 +1388,13 @@
"workingStatus": "处理中……",
"you": "你",
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens",
"noMessage": ""
"noMessage": "",
"modelAgentSection": "Model / Agent",
"thinkingLevelSection": "Thinking level",
"noModelsAvailable": "No models available",
"currentAgentTarget": "Current agent: {{name}}",
"currentModelTarget": "Current model: {{model}}",
"currentDefaultTarget": "Using the default chat target"
},
"chatRooms": {
"error": {

View File

@@ -1388,7 +1388,13 @@
"workingStatus": "處理中……",
"you": "你",
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens",
"noMessage": ""
"noMessage": "",
"modelAgentSection": "Model / Agent",
"thinkingLevelSection": "Thinking level",
"noModelsAvailable": "No models available",
"currentAgentTarget": "Current agent: {{name}}",
"currentModelTarget": "Current model: {{model}}",
"currentDefaultTarget": "Using the default chat target"
},
"chatRooms": {
"error": {