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>
358 lines
15 KiB
TypeScript
358 lines
15 KiB
TypeScript
// ChatView thinking-level control mount test (FN-7898).
|
|
//
|
|
// Asserts the Brain-icon ChatThinkingLevelControl renders ONLY in the direct-session
|
|
// (non-CLI) composer, next to the attach button, and never renders for CLI-backed
|
|
// sessions, never in the rooms composer, and never with no active session. Also verifies
|
|
// the control's displayed state tracks the active session across a session switch, and
|
|
// that it renders without layout regression at a mobile viewport.
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { act, fireEvent, render as rtlRender, screen } from "@testing-library/react";
|
|
import { ChatView } from "../ChatView";
|
|
import * as api from "../../api";
|
|
import * as useChatModule from "../../hooks/useChat";
|
|
import * as useChatRoomsModule from "../../hooks/useChatRooms";
|
|
import type { ChatSessionInfo, UseChatReturn } from "../../hooks/useChat";
|
|
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
|
|
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
|
|
|
Element.prototype.scrollIntoView = vi.fn();
|
|
|
|
vi.mock("../SessionTerminal", () => ({
|
|
SessionTerminal: ({ sessionId }: { sessionId: string }) => (
|
|
<div data-testid="session-terminal" data-session-id={sessionId}>
|
|
terminal
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("../../hooks/useChat");
|
|
vi.mock("../../hooks/useChatRooms");
|
|
vi.mock("../CustomModelDropdown", () => ({
|
|
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) => {
|
|
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
|
return {
|
|
...actual,
|
|
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
|
};
|
|
});
|
|
vi.mock("../../api", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../../api")>();
|
|
return {
|
|
...actual,
|
|
fetchAgents: vi.fn().mockResolvedValue([]),
|
|
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
|
|
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
|
|
fetchTasks: vi.fn().mockResolvedValue([]),
|
|
fetchSettings: vi.fn().mockResolvedValue({}),
|
|
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
|
|
};
|
|
});
|
|
|
|
async function renderWithAct(ui: Parameters<typeof rtlRender>[0]) {
|
|
let result: ReturnType<typeof rtlRender> | undefined;
|
|
await act(async () => {
|
|
result = rtlRender(ui);
|
|
});
|
|
return result!;
|
|
}
|
|
|
|
const mockUseChat = vi.mocked(useChatModule.useChat);
|
|
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
|
|
const mockFetchSettings = vi.mocked(api.fetchSettings);
|
|
|
|
function makeSession(overrides: Partial<ChatSessionInfo> = {}): ChatSessionInfo {
|
|
return {
|
|
id: "sess-1",
|
|
agentId: "agent-1",
|
|
status: "active",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function chatState(overrides: Partial<UseChatReturn> = {}): UseChatReturn {
|
|
const session = "activeSession" in overrides ? overrides.activeSession ?? null : makeSession();
|
|
return {
|
|
sessions: session ? [session] : [],
|
|
activeSession: session,
|
|
sessionsLoading: false,
|
|
messages: [],
|
|
messagesLoading: false,
|
|
isStreaming: false,
|
|
streamingText: "",
|
|
streamingThinking: "",
|
|
streamingToolCalls: [],
|
|
selectSession: vi.fn(),
|
|
createSession: vi.fn(),
|
|
archiveSession: vi.fn(),
|
|
renameSession: vi.fn(),
|
|
setSessionModel: vi.fn(),
|
|
setSessionThinkingLevel: vi.fn(),
|
|
deleteSession: vi.fn(),
|
|
sendMessage: vi.fn(),
|
|
editMessageAndResend: vi.fn(),
|
|
stopStreaming: vi.fn(),
|
|
pendingMessages: [],
|
|
clearPendingMessage: vi.fn(),
|
|
loadMoreMessages: vi.fn(),
|
|
hasMoreMessages: false,
|
|
searchQuery: "",
|
|
setSearchQuery: vi.fn(),
|
|
filteredSessions: session ? [session] : [],
|
|
refreshSessions: vi.fn(),
|
|
agentsMap: new Map(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const roomA = {
|
|
id: "room-a",
|
|
name: "Room A",
|
|
slug: "room-a",
|
|
description: null,
|
|
projectId: "proj-123",
|
|
createdBy: "agent-1",
|
|
status: "active" as const,
|
|
createdAt: "2026-04-08T00:00:00.000Z",
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
};
|
|
|
|
function roomsState(overrides: Partial<UseChatRoomsResult> = {}): UseChatRoomsResult {
|
|
return {
|
|
rooms: [],
|
|
roomsLoading: false,
|
|
roomsError: null,
|
|
activeRoom: null,
|
|
activeRoomMembers: [],
|
|
messages: [],
|
|
messagesLoading: false,
|
|
selectRoom: vi.fn(),
|
|
createRoom: vi.fn(),
|
|
deleteRoom: vi.fn(),
|
|
sendRoomMessage: vi.fn(),
|
|
refreshRooms: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function mockDesktopViewport() {
|
|
if (!window.matchMedia) {
|
|
Object.defineProperty(window, "matchMedia", { value: vi.fn(), configurable: true, writable: true });
|
|
}
|
|
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
|
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
|
matches: false,
|
|
media: query,
|
|
onchange: null,
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
}) as unknown as MediaQueryList);
|
|
}
|
|
|
|
function mockMobileViewport() {
|
|
if (!window.matchMedia) {
|
|
Object.defineProperty(window, "matchMedia", { value: vi.fn(), configurable: true, writable: true });
|
|
}
|
|
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
|
|
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
|
matches: query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)",
|
|
media: query,
|
|
onchange: null,
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
}) as unknown as MediaQueryList);
|
|
}
|
|
|
|
describe("ChatView thinking-level control (FN-7898)", () => {
|
|
beforeEach(() => {
|
|
_resetInitialViewportHeight();
|
|
vi.clearAllMocks();
|
|
mockFetchSettings.mockResolvedValue({} as Awaited<ReturnType<typeof api.fetchSettings>>);
|
|
mockDesktopViewport();
|
|
mockUseChatRooms.mockReturnValue(roomsState());
|
|
});
|
|
|
|
it("(a) renders in the direct-session composer for a non-CLI active session and selecting a level calls setSessionThinkingLevel(session.id, level)", async () => {
|
|
const setSessionThinkingLevel = vi.fn();
|
|
const session = makeSession({ id: "sess-a", cliExecutorAdapterId: null, thinkingLevel: null });
|
|
mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session], setSessionThinkingLevel }));
|
|
|
|
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const trigger = screen.getByTestId("chat-thinking-btn");
|
|
expect(trigger).toBeInTheDocument();
|
|
|
|
fireEvent.click(trigger);
|
|
fireEvent.click(screen.getByTestId("chat-thinking-option-high"));
|
|
|
|
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] }));
|
|
|
|
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
expect(screen.queryByTestId("chat-thinking-btn")).toBeNull();
|
|
});
|
|
|
|
it("(c) does NOT render in the rooms composer when chatScope is rooms with an active room", async () => {
|
|
const session = makeSession({ id: "sess-a", cliExecutorAdapterId: null });
|
|
mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session] }));
|
|
mockUseChatRooms.mockReturnValue(roomsState({ rooms: [roomA], activeRoom: roomA }));
|
|
localStorage.setItem("fusion:chat-scope", "rooms");
|
|
|
|
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
|
|
|
// The rooms composer's own attach button is present (proves the rooms
|
|
// composer rendered), but no thinking-level trigger exists anywhere.
|
|
expect(screen.getAllByTestId("chat-attach-btn").length).toBeGreaterThan(0);
|
|
expect(screen.queryByTestId("chat-thinking-btn")).toBeNull();
|
|
|
|
localStorage.removeItem("fusion:chat-scope");
|
|
});
|
|
|
|
it("(d) does NOT render when there is no active session", async () => {
|
|
mockUseChat.mockReturnValue(chatState({ activeSession: null, sessions: [] }));
|
|
|
|
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
expect(screen.queryByTestId("chat-thinking-btn")).toBeNull();
|
|
});
|
|
|
|
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 () => {
|
|
let result: ReturnType<typeof rtlRender> | undefined;
|
|
await act(async () => {
|
|
result = rtlRender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
});
|
|
return result!;
|
|
})();
|
|
|
|
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, 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.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 () => {
|
|
mockMobileViewport();
|
|
const session = makeSession({ id: "sess-mobile", cliExecutorAdapterId: null, thinkingLevel: null });
|
|
mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session] }));
|
|
|
|
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
const trigger = screen.getByTestId("chat-thinking-btn");
|
|
expect(trigger).toBeInTheDocument();
|
|
// Shell/structure assertion consistent with other ChatView mobile tests:
|
|
// the trigger sits alongside the attach button inside the same input row.
|
|
expect(trigger.closest(".chat-input-row")).not.toBeNull();
|
|
expect(trigger.closest(".chat-thinking-level-root")).not.toBeNull();
|
|
});
|
|
|
|
it("(g) uses the resolved Settings default for both the in-chat and New Chat thinking-level labels", async () => {
|
|
mockFetchSettings.mockResolvedValue({ defaultThinkingLevel: "medium" } as Awaited<ReturnType<typeof api.fetchSettings>>);
|
|
const session = makeSession({ id: "sess-default-medium", cliExecutorAdapterId: null, thinkingLevel: null });
|
|
mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session] }));
|
|
|
|
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
|
expect(await screen.findByText("Default (medium)")).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getAllByTestId("chat-new-btn")[0]);
|
|
fireEvent.click(screen.getByTestId("chat-new-dialog-mode-model"));
|
|
|
|
expect(await screen.findByTestId("custom-model-dropdown")).toHaveAttribute("data-default-thinking", "medium");
|
|
});
|
|
|
|
it("(h) falls back to off for both chat thinking-level surfaces when Settings has no default", async () => {
|
|
mockFetchSettings.mockResolvedValue({} as Awaited<ReturnType<typeof api.fetchSettings>>);
|
|
const session = makeSession({ id: "sess-default-off", cliExecutorAdapterId: null, thinkingLevel: null });
|
|
mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session] }));
|
|
|
|
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
fireEvent.click(screen.getByTestId("chat-thinking-btn"));
|
|
expect(screen.getByText("Default (off)")).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getAllByTestId("chat-new-btn")[0]);
|
|
fireEvent.click(screen.getByTestId("chat-new-dialog-mode-model"));
|
|
|
|
expect(await screen.findByTestId("custom-model-dropdown")).toHaveAttribute("data-default-thinking", "off");
|
|
});
|
|
});
|