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:
7
.changeset/fn-7908-chat-model-agent-switcher.md
Normal file
7
.changeset/fn-7908-chat-model-agent-switcher.md
Normal 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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user