diff --git a/.changeset/fn-8030-room-thinking-composer.md b/.changeset/fn-8030-room-thinking-composer.md new file mode 100644 index 0000000000..ac8aa28ae2 --- /dev/null +++ b/.changeset/fn-8030-room-thinking-composer.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Move the room thinking-effort control from the room header into the composer Brain icon next to attach. +category: fix +dev: Rooms now reuse ChatThinkingLevelControl in level-only mode (showTargetSection={false}); header { - const selectedLevel = event.target.value; - const thinkingLevel = THINKING_LEVELS.includes(selectedLevel as ThinkingLevel) ? selectedLevel : null; - void rooms.updateRoomSettings(rooms.activeRoom!.id, { thinkingLevel }).catch(() => { - addToast(t("chat.failedToUpdateRoomThinkingLevel", "Failed to update room thinking effort"), "error"); - }); - }} - > - - {THINKING_LEVELS.map((level) => ( - - ))} - -
{rooms.activeRoomMembers.map((member) => ( + {/* + FNXC:Chat-ThinkingLevel 2026-07-16-00:34: + FN-8030 moves room thinking effort from the crowded thread header to this Brain-icon + popover beside attach, matching direct chat while keeping it reachable on narrow layouts. + It persists one responder-wide room default and intentionally exposes no model/agent target. + */} + { + void rooms.updateRoomSettings(rooms.activeRoom!.id, { thinkingLevel: level || null }).catch(() => { + addToast(t("chat.failedToUpdateRoomThinkingLevel", "Failed to update room thinking effort"), "error"); + }); + }} + />
{ diff --git a/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.test.tsx b/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.test.tsx index 9c6e26cb07..3cb640c3d7 100644 --- a/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.test.tsx @@ -68,6 +68,26 @@ describe("ChatThinkingLevelControl", () => { expect(screen.getAllByRole("option")).toHaveLength(THINKING_LEVELS.length + 1); }); + it("renders only thinking-level options in level-only mode and persists selections", () => { + const onChange = vi.fn(); + render(); + + expect(screen.getByTestId("chat-thinking-btn").className).toContain("chat-thinking-btn--active"); + fireEvent.click(screen.getByTestId("chat-thinking-btn")); + + expect(screen.getByRole("listbox")).toBeDefined(); + expect(screen.queryByTestId("chat-thinking-mode-toggle")).toBeNull(); + expect(screen.queryByTestId("chat-thinking-model-picker")).toBeNull(); + expect(screen.getByTestId("chat-thinking-option-high")).toBeDefined(); + + fireEvent.click(screen.getByTestId("chat-thinking-option-high")); + expect(onChange).toHaveBeenCalledWith("high"); + + fireEvent.click(screen.getByTestId("chat-thinking-btn")); + fireEvent.click(screen.getByTestId("chat-thinking-option-default")); + expect(onChange).toHaveBeenCalledWith(""); + }); + it("labels Default with the supplied resolved project/global thinking default", () => { render(); diff --git a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx index a4e5fb0450..df8a1bf1eb 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx @@ -367,25 +367,81 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { }); }); - it("renders room header thinking picker and updates room settings", async () => { + it("renders the room composer thinking control beside attach and updates room settings", async () => { const updateRoomSettings = vi.fn().mockResolvedValue({ ...roomA, thinkingLevel: "high" }); setup({}, { activeRoom: { ...roomA, thinkingLevel: "medium" }, updateRoomSettings }); const { container } = await renderWithAct(); - const select = screen.getByTestId("chat-room-thinking-level") as HTMLSelectElement; - expect(select.value).toBe("medium"); - expect(within(select).getByRole("option", { name: "Use default" })).toBeDefined(); - for (const label of ["Off", "Minimal", "Low", "Medium", "High", "Very High"]) { - expect(within(select).getByRole("option", { name: label })).toBeDefined(); - } + const header = container.querySelector(".chat-room-thread-header"); + expect(header?.querySelector("[data-testid='chat-room-thinking-level']")).toBeNull(); + expect(header?.querySelector("label[for='chat-room-thinking-level']")).toBeNull(); + expect(header?.querySelector(".chat-room-thinking-level-field")).toBeNull(); - await userEvent.selectOptions(select, "high"); + const attachButton = screen.getByTestId("chat-attach-btn"); + const thinkingButton = screen.getByTestId("chat-thinking-btn"); + expect(attachButton.nextElementSibling).toContainElement(thinkingButton); + + await userEvent.click(thinkingButton); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + expect(screen.getByTestId("chat-thinking-option-default")).toHaveTextContent(/Default/); + for (const label of ["Off", "Minimal", "Low", "Medium", "High", "Very High"]) { + expect(screen.getByRole("option", { name: label })).toBeInTheDocument(); + } + expect(screen.queryByTestId("chat-thinking-mode-toggle")).toBeNull(); + expect(screen.queryByTestId("chat-thinking-model-picker")).toBeNull(); + + await userEvent.click(screen.getByTestId("chat-thinking-option-high")); expect(updateRoomSettings).toHaveBeenCalledWith("room-a", { thinkingLevel: "high" }); - await userEvent.selectOptions(select, ""); + await userEvent.click(thinkingButton); + await userEvent.click(screen.getByTestId("chat-thinking-option-default")); expect(updateRoomSettings).toHaveBeenCalledWith("room-a", { thinkingLevel: null }); - expect(container.querySelector(".chat-input-area [data-testid='chat-room-thinking-level']")).toBeNull(); + }); + + it("shows a room thinking update failure toast", async () => { + const addToast = vi.fn(); + const updateRoomSettings = vi.fn().mockRejectedValue(new Error("update failed")); + setup({}, { updateRoomSettings }); + + await renderWithAct(); + + await userEvent.click(screen.getByTestId("chat-thinking-btn")); + await userEvent.click(screen.getByTestId("chat-thinking-option-high")); + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith("Failed to update room thinking effort", "error"); + }); + }); + + it("keeps the level-only thinking control reachable beside attach on mobile", async () => { + const viewportSpy = mockMobileViewport(); + + const { container } = await renderWithAct(); + + const header = container.querySelector(".chat-room-thread-header"); + expect(header).toBeNull(); + expect(container.querySelector("[data-testid='chat-room-thinking-level']")).toBeNull(); + expect(container.querySelector("label[for='chat-room-thinking-level']")).toBeNull(); + expect(container.querySelector(".chat-room-thinking-level-field")).toBeNull(); + + const attachButton = screen.getByTestId("chat-attach-btn"); + const thinkingButton = screen.getByTestId("chat-thinking-btn"); + expect(attachButton.nextElementSibling).toContainElement(thinkingButton); + await userEvent.click(thinkingButton); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + expect(screen.queryByTestId("chat-thinking-mode-toggle")).toBeNull(); + expect(screen.queryByTestId("chat-thinking-model-picker")).toBeNull(); + + viewportSpy.mockRestore(); + }); + + it("omits the room composer thinking control when no room is active", async () => { + setup({}, { activeRoom: null }); + + await renderWithAct(); + + expect(screen.queryByTestId("chat-thinking-btn")).toBeNull(); }); it("passes attachment file list shape to room sends", async () => { diff --git a/packages/dashboard/app/components/__tests__/ChatView.thinking-level.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.thinking-level.test.tsx index 52caac72b7..e762739487 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.thinking-level.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.thinking-level.test.tsx @@ -1,8 +1,9 @@ // 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 +// Asserts the Brain-icon ChatThinkingLevelControl renders in the direct-session +// (non-CLI) composer and the active room composer next to attach. Direct chat retains its +// Model / Agent target section; rooms are level-only. It never renders for CLI-backed +// sessions or direct chat 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"; @@ -195,6 +196,7 @@ describe("ChatView thinking-level control (FN-7898)", () => { _resetInitialViewportHeight(); vi.clearAllMocks(); mockFetchSettings.mockResolvedValue({} as Awaited>); + localStorage.setItem("fusion:chat-scope", "direct"); mockDesktopViewport(); mockUseChatRooms.mockReturnValue(roomsState()); }); @@ -255,7 +257,7 @@ describe("ChatView thinking-level control (FN-7898)", () => { 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 () => { + it("(c) renders a level-only control beside attach in the rooms composer", async () => { const session = makeSession({ id: "sess-a", cliExecutorAdapterId: null }); mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session] })); mockUseChatRooms.mockReturnValue(roomsState({ rooms: [roomA], activeRoom: roomA })); @@ -263,12 +265,15 @@ describe("ChatView thinking-level control (FN-7898)", () => { await renderWithAct(); - // 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(); + const attachButton = screen.getByTestId("chat-attach-btn"); + const thinkingButton = screen.getByTestId("chat-thinking-btn"); + expect(attachButton.nextElementSibling).toContainElement(thinkingButton); + fireEvent.click(thinkingButton); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + expect(screen.queryByTestId("chat-thinking-mode-toggle")).toBeNull(); + expect(screen.queryByTestId("chat-thinking-model-picker")).toBeNull(); - localStorage.removeItem("fusion:chat-scope"); + localStorage.setItem("fusion:chat-scope", "direct"); }); it("(d) does NOT render when there is no active session", async () => {