Files
fusion/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.test.tsx
gsxdsm 8835c6cb48 FN-7908: add in-chat model/agent switcher to brain-icon popup
Extend the chat brain-icon popup and its backing session PATCH route so an
active Direct chat's model or agent can be switched mid-conversation instead
of only being set at creation time.

- Add a Model/Agent section to ChatThinkingLevelControl (the brain-icon
  popup) for picking a model provider/model or retargeting to a real agent
  without leaving the chat.
- Extend PATCH /api/chat/sessions/:id to accept modelProvider/modelId (as a
  validated pair via the existing validateModelPair helper) and agentId,
  forwarding only the keys present in the body so omitted fields leave the
  session's stored target untouched.
- Add chat-store updateSession support for the agentId clause alongside the
  existing model/thinkingLevel fields, and a useChat.setSessionModel hook
  for the dashboard to call the new PATCH capability.
- Update i18n locale strings (en/es/fr/ko/zh-CN/zh-TW) and dashboard-guide.md
  docs for the new switcher UI.
- Add unit/integration test coverage across chat-store, chat-manager,
  chat-routes, useChat, ChatThinkingLevelControl, and ChatView for the new
  model/agent switch behavior.
- Add changeset fn-7908-chat-model-agent-switcher.md (minor,
  @runfusion/fusion).

Files changed:
 .changeset/fn-7908-chat-model-agent-switcher.md    |   7 +
 docs/dashboard-guide.md                            |   3 +-
 packages/core/src/__tests__/chat-store.test.ts     |  21 ++
 packages/core/src/chat-store.ts                    |   8 +
 packages/core/src/chat-types.ts                    |   2 +
 packages/dashboard/app/api/legacy.ts               |  11 +-
 .../app/components/ChatThinkingLevelControl.tsx    | 219 ++++++++++++++++++---
 packages/dashboard/app/components/ChatView.css     | 135 ++++++++++++-
 packages/dashboard/app/components/ChatView.tsx     |  23 ++-
 .../__tests__/ChatThinkingLevelControl.test.tsx    | 109 +++++++++-
 .../__tests__/ChatView.thinking-level.test.tsx     |  67 ++++++-
 .../dashboard/app/hooks/__tests__/useChat.test.ts  | 166 +++++++++++++++-
 packages/dashboard/app/hooks/useChat.ts            |  56 ++++++
 .../dashboard/src/__tests__/chat-manager.test.ts   |  38 ++++
 .../dashboard/src/__tests__/chat-routes.test.ts    | 117 ++++++++++-
 .../dashboard/src/routes/register-chat-routes.ts   |  48 ++++-
 packages/i18n/locales/en/app.json                  |   8 +-
 packages/i18n/locales/es/app.json                  |   8 +-
 packages/i18n/locales/fr/app.json                  |   8 +-
 packages/i18n/locales/ko/app.json                  |   8 +-
 packages/i18n/locales/zh-CN/app.json               |   8 +-
 packages/i18n/locales/zh-TW/app.json               |   8 +-
 22 files changed, 1007 insertions(+), 71 deletions(-)

Fusion-Task-Id: FN-7908

Fusion-Task-Lineage: b1104865-9b0c-4d77-973e-89152fe245e0

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-12 21:33:50 -07:00

225 lines
9.0 KiB
TypeScript

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();
});
it("renders the Brain trigger and no popup by default", () => {
render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} />);
const trigger = screen.getByTestId("chat-thinking-btn");
expect(trigger).toBeDefined();
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByRole("listbox")).toBeNull();
});
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();
}
expect(screen.getAllByRole("option")).toHaveLength(THINKING_LEVELS.length + 1);
});
it("labels Default with the supplied resolved project/global thinking default", () => {
render(<ChatThinkingLevelControl level={null} defaultThinkingLevel="medium" onChange={vi.fn()} />);
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
expect(screen.getByTestId("chat-thinking-option-default")).toHaveTextContent("Default (medium)");
});
it("falls back to Default (off) when no resolved default is supplied", () => {
render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} />);
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
expect(screen.getByTestId("chat-thinking-option-default")).toHaveTextContent("Default (off)");
});
it("selecting a level calls onChange with that level and closes the popup", () => {
const onChange = vi.fn();
render(<ChatThinkingLevelControl level={null} onChange={onChange} />);
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
fireEvent.click(screen.getByTestId("chat-thinking-option-high"));
expect(onChange).toHaveBeenCalledWith("high");
expect(screen.queryByRole("listbox")).toBeNull();
});
it("selecting Default calls onChange with an empty string", () => {
const onChange = vi.fn();
render(<ChatThinkingLevelControl level="high" onChange={onChange} />);
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
fireEvent.click(screen.getByTestId("chat-thinking-option-default"));
expect(onChange).toHaveBeenCalledWith("");
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} />);
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
expect(screen.getByRole("listbox")).toBeDefined();
fireEvent.pointerDown(document.body);
expect(screen.queryByRole("listbox")).toBeNull();
expect(onChange).not.toHaveBeenCalled();
});
it("Escape closes the popup", () => {
render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} />);
const trigger = screen.getByTestId("chat-thinking-btn");
fireEvent.click(trigger);
expect(screen.getByRole("listbox")).toBeDefined();
fireEvent.keyDown(trigger, { key: "Escape" });
expect(screen.queryByRole("listbox")).toBeNull();
});
it("shows the active-state class only when level is a concrete value", () => {
const { rerender } = render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} />);
expect(screen.getByTestId("chat-thinking-btn").className).not.toContain("chat-thinking-btn--active");
rerender(<ChatThinkingLevelControl level={undefined} onChange={vi.fn()} />);
expect(screen.getByTestId("chat-thinking-btn").className).not.toContain("chat-thinking-btn--active");
rerender(<ChatThinkingLevelControl level="" onChange={vi.fn()} />);
expect(screen.getByTestId("chat-thinking-btn").className).not.toContain("chat-thinking-btn--active");
rerender(<ChatThinkingLevelControl level="medium" onChange={vi.fn()} />);
expect(screen.getByTestId("chat-thinking-btn").className).toContain("chat-thinking-btn--active");
});
it("disabled prevents opening", () => {
render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} disabled />);
const trigger = screen.getByTestId("chat-thinking-btn");
expect(trigger).toBeDisabled();
fireEvent.click(trigger);
expect(screen.queryByRole("listbox")).toBeNull();
});
});