FN-8030: move room thinking control into composer

Move room-level thinking effort from the crowded thread header into the composer Brain popover.

- Reuse the thinking-level control in a room-specific level-only mode.
- Preserve room thinking persistence and add desktop, mobile, and failure coverage.
- Update room-chat documentation and publish a patch changeset.

Files changed:
 .changeset/fn-8030-room-thinking-composer.md       |  7 +++
 docs/dashboard-guide.md                            |  2 +-
 .../app/components/ChatThinkingLevelControl.tsx    | 12 +++-
 packages/dashboard/app/components/ChatView.css     | 16 -----
 packages/dashboard/app/components/ChatView.tsx     | 54 +++++++---------
 .../__tests__/ChatThinkingLevelControl.test.tsx    | 20 ++++++
 .../components/__tests__/ChatView.rooms.test.tsx   | 72 +++++++++++++++++++---
 .../__tests__/ChatView.thinking-level.test.tsx     | 23 ++++---
 8 files changed, 137 insertions(+), 69 deletions(-)

Fusion-Task-Id: FN-8030

Fusion-Task-Lineage: 31054538-e620-412d-98ba-26fba34a6af8

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 01:38:49 -07:00
parent e46ffebde1
commit a821bce236
8 changed files with 139 additions and 71 deletions

View File

@@ -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 <select> and its CSS/shell removed. Persistence via rooms.updateRoomSettings({ thinkingLevel }) unchanged.

View File

@@ -592,7 +592,7 @@ Chat Rooms are project-scoped group conversations for multiple agents. They are
- Each room row includes a trash action (`aria-label="Delete room {name}"`, `data-testid="chat-room-delete-{slug}"`) that opens a **Delete Room?** confirmation dialog with **Cancel** and **Delete** actions. - Each room row includes a trash action (`aria-label="Delete room {name}"`, `data-testid="chat-room-delete-{slug}"`) that opens a **Delete Room?** confirmation dialog with **Cancel** and **Delete** actions.
- Confirming delete calls `rooms.deleteRoom(roomId)` and permanently removes the room and its messages ("This action cannot be undone. This room and all its messages will be permanently deleted."); failures surface a `Failed to delete room` toast. - Confirming delete calls `rooms.deleteRoom(roomId)` and permanently removes the room and its messages ("This action cannot be undone. This room and all its messages will be permanently deleted."); failures surface a `Failed to delete room` toast.
- Selecting a room opens the room thread pane with loading and empty states, then renders room messages from `rooms.messages` as `ChatMessageInfo` entries in the same thread UI used for direct Chat. - Selecting a room opens the room thread pane with loading and empty states, then renders room messages from `rooms.messages` as `ChatMessageInfo` entries in the same thread UI used for direct Chat.
- The room header includes a **Thinking effort** selector with **Use default**, **off**, **minimal**, **low**, **medium**, **high**, and **Very High**. It stores one room-level default for every responder in that room; **Use default** clears the room override so responders inherit the resolved project/global reasoning-effort default. Per-member thinking overrides are not supported. - The room composer includes a compact **Brain** thinking popover next to the attach button, mirroring direct chat, with **Default**, **off**, **minimal**, **low**, **medium**, **high**, and **Very High**. It stores one room-level default for every responder in that room; **Default** clears the room override so responders inherit the resolved project/global reasoning-effort default. Per-member thinking overrides are not supported.
- Submitting the room composer calls `rooms.sendRoomMessage(...)`, which immediately inserts a temporary local user message and then posts to `POST /api/chat/rooms/:id/messages`. - Submitting the room composer calls `rooms.sendRoomMessage(...)`, which immediately inserts a temporary local user message and then posts to `POST /api/chat/rooms/:id/messages`.
- The room composer clears immediately when send is dispatched so the user gets instant feedback; on success the optimistic message is reconciled with persisted server data and the transcript is refreshed to authoritative history. - The room composer clears immediately when send is dispatched so the user gets instant feedback; on success the optimistic message is reconciled with persisted server data and the transcript is refreshed to authoritative history.
- On mobile, room threads use the same keyboard-aware thread anchoring as direct chat, keeping the composer pinned above the soft keyboard while typing. - On mobile, room threads use the same keyboard-aware thread anchoring as direct chat, keeping the composer pinned above the soft keyboard while typing.

View File

@@ -22,7 +22,10 @@ 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)`. 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: 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. 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 composers stay gated in ChatView.
FNXC:Chat-ThinkingLevel 2026-07-16-00:34:
FN-8030 lets room composers reuse this control with showTargetSection={false}. A room's thinking effort is the default reasoning effort for every responder, and rooms have no per-composer model or agent target to switch.
*/ */
export interface ChatThinkingLevelControlAgent { export interface ChatThinkingLevelControlAgent {
@@ -38,6 +41,8 @@ export interface ChatThinkingLevelControlProps {
onChange: (level: string) => void | Promise<void>; onChange: (level: string) => void | Promise<void>;
/** Resolved project/global default used only for the Default/clear label. */ /** Resolved project/global default used only for the Default/clear label. */
defaultThinkingLevel?: string; defaultThinkingLevel?: string;
/** Show direct-chat model/agent targeting controls; rooms render only the thinking-level list. */
showTargetSection?: boolean;
models?: ModelInfo[]; models?: ModelInfo[];
favoriteProviders?: string[]; favoriteProviders?: string[];
favoriteModels?: string[]; favoriteModels?: string[];
@@ -56,6 +61,7 @@ export function ChatThinkingLevelControl({
level, level,
onChange, onChange,
defaultThinkingLevel = "off", defaultThinkingLevel = "off",
showTargetSection = true,
models = [], models = [],
favoriteProviders = [], favoriteProviders = [],
favoriteModels = [], favoriteModels = [],
@@ -73,7 +79,7 @@ export function ChatThinkingLevelControl({
const normalizedLevel = level ?? ""; const normalizedLevel = level ?? "";
const currentModelValue = modelProvider && modelId ? `${modelProvider}/${modelId}` : ""; const currentModelValue = modelProvider && modelId ? `${modelProvider}/${modelId}` : "";
const selectedAgentId = agentId && agentId !== FN_AGENT_ID ? agentId : ""; const selectedAgentId = agentId && agentId !== FN_AGENT_ID ? agentId : "";
const isActive = normalizedLevel !== "" || Boolean(currentModelValue) || Boolean(selectedAgentId); const isActive = normalizedLevel !== "" || (showTargetSection && (Boolean(currentModelValue) || Boolean(selectedAgentId)));
const listboxId = "chat-thinking-level-listbox"; const listboxId = "chat-thinking-level-listbox";
useEffect(() => { useEffect(() => {
@@ -183,6 +189,7 @@ export function ChatThinkingLevelControl({
{open ? ( {open ? (
<div className="chat-thinking-popover" role="presentation" data-testid="chat-thinking-popover"> <div className="chat-thinking-popover" role="presentation" data-testid="chat-thinking-popover">
{showTargetSection ? (
<section className="chat-thinking-target-section" aria-label={t("chat.modelAgentSection", "Model / Agent")}> <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-section-title">{t("chat.modelAgentSection", "Model / Agent")}</div>
<div className="chat-thinking-mode-toggle" data-testid="chat-thinking-mode-toggle"> <div className="chat-thinking-mode-toggle" data-testid="chat-thinking-mode-toggle">
@@ -266,6 +273,7 @@ export function ChatThinkingLevelControl({
</div> </div>
)} )}
</section> </section>
) : null}
<section className="chat-thinking-level-section" aria-label={t("chat.thinkingLevelButton", "Thinking level")}> <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 className="chat-thinking-section-title">{t("chat.thinkingLevelSection", "Thinking level")}</div>

View File

@@ -250,17 +250,6 @@ When the movable chat popup is resized narrow, collapse Direct/Rooms labels to i
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.chat-room-thinking-level-field {
display: flex;
align-items: center;
flex: 0 1 calc(var(--space-2xl) * 6);
min-width: min-content;
}
.chat-room-thinking-level-select {
width: 100%;
}
.chat-room-thread-members { .chat-room-thread-members {
margin-left: auto; margin-left: auto;
display: flex; display: flex;
@@ -2678,11 +2667,6 @@ Queued-message banners stack above the composer input with a capped scroll area,
padding: var(--space-sm) var(--space-md); padding: var(--space-sm) var(--space-md);
} }
.chat-room-thinking-level-field {
min-width: 0;
flex: 1 1 auto;
}
.chat-room-thread-header .btn-icon { .chat-room-thread-header .btn-icon {
min-height: 36px; min-height: 36px;
min-width: 36px; min-width: 36px;

View File

@@ -24,7 +24,7 @@ import { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../hooks/
import { useChatUnread } from "../hooks/useChatUnread"; import { useChatUnread } from "../hooks/useChatUnread";
import { useViewportMode } from "./Header"; import { useViewportMode } from "./Header";
import { fetchSettings, updateGlobalSettings, type DiscoveredSkill } from "../api"; import { fetchSettings, updateGlobalSettings, type DiscoveredSkill } from "../api";
import { THINKING_LEVELS, type Agent, type Settings, type ThinkingLevel } from "@fusion/core"; import { type Agent, type Settings } from "@fusion/core";
import { CustomModelDropdown } from "./CustomModelDropdown"; import { CustomModelDropdown } from "./CustomModelDropdown";
import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl"; import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl";
import { AgentMentionPopup } from "./AgentMentionPopup"; import { AgentMentionPopup } from "./AgentMentionPopup";
@@ -2809,11 +2809,10 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
<Paperclip size={16} /> <Paperclip size={16} />
</button> </button>
{/* {/*
FNXC:Chat-ThinkingLevel 2026-07-12-19:30: FNXC:Chat-ThinkingLevel 2026-07-16-00:34:
FN-7898: change the active session's thinking level mid-conversation from the composer. FN-8030: direct sessions retain model/agent targeting here, while room composers reuse
Model-loop (non-CLI) direct sessions only — CLI-backed sessions broker to a live PTY and this control in level-only mode. CLI-backed sessions broker to a live PTY and never receive
never receive defaultThinkingLevel (FN-7775), and chat rooms have no thinkingLevel field defaultThinkingLevel (FN-7775), so this direct-chat control stays gated by cliChatActive.
at all. Gate with the existing cliChatActive boolean already in scope here.
*/} */}
{!cliChatActive && ( {!cliChatActive && (
<ChatThinkingLevelControl <ChatThinkingLevelControl
@@ -3546,33 +3545,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</div> </div>
)} )}
</div> </div>
<div className="chat-room-thinking-level-field">
{/* FNXC:Chat-ThinkingLevel 2026-07-12-00:00: Room thinking effort is a header-level room setting, not a composer control, because it acts as the default reasoning effort for every responder in the conversation. */}
<label className="sr-only" htmlFor="chat-room-thinking-level">
{t("chat.roomThinkingLevel", "Room thinking effort")}
</label>
<select
id="chat-room-thinking-level"
className="input chat-room-thinking-level-select"
data-testid="chat-room-thinking-level"
aria-label={t("chat.roomThinkingLevel", "Room thinking effort")}
value={rooms.activeRoom.thinkingLevel ?? ""}
onChange={(event) => {
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");
});
}}
>
<option value="">{t("models.useDefault", "Use default")}</option>
{THINKING_LEVELS.map((level) => (
<option key={level} value={level}>
{t(`models.options.${level}`, level === "xhigh" ? "Very High" : level.charAt(0).toUpperCase() + level.slice(1))}
</option>
))}
</select>
</div>
<div className="chat-room-thread-members"> <div className="chat-room-thread-members">
{rooms.activeRoomMembers.map((member) => ( {rooms.activeRoomMembers.map((member) => (
<AgentAvatar <AgentAvatar
@@ -3697,6 +3669,22 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
> >
<Paperclip size={16} /> <Paperclip size={16} />
</button> </button>
{/*
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.
*/}
<ChatThinkingLevelControl
level={rooms.activeRoom.thinkingLevel}
defaultThinkingLevel={resolvedDefaultThinkingLevel}
showTargetSection={false}
onChange={(level) => {
void rooms.updateRoomSettings(rooms.activeRoom!.id, { thinkingLevel: level || null }).catch(() => {
addToast(t("chat.failedToUpdateRoomThinkingLevel", "Failed to update room thinking effort"), "error");
});
}}
/>
<div <div
className={`chat-input-wrapper${isDragOver ? " chat-input-wrapper--dragover" : ""}`} className={`chat-input-wrapper${isDragOver ? " chat-input-wrapper--dragover" : ""}`}
onDragOver={(event) => { onDragOver={(event) => {

View File

@@ -68,6 +68,26 @@ describe("ChatThinkingLevelControl", () => {
expect(screen.getAllByRole("option")).toHaveLength(THINKING_LEVELS.length + 1); 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(<ChatThinkingLevelControl level="medium" onChange={onChange} showTargetSection={false} models={models} agents={agents} />);
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", () => { it("labels Default with the supplied resolved project/global thinking default", () => {
render(<ChatThinkingLevelControl level={null} defaultThinkingLevel="medium" onChange={vi.fn()} />); render(<ChatThinkingLevelControl level={null} defaultThinkingLevel="medium" onChange={vi.fn()} />);

View File

@@ -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" }); const updateRoomSettings = vi.fn().mockResolvedValue({ ...roomA, thinkingLevel: "high" });
setup({}, { activeRoom: { ...roomA, thinkingLevel: "medium" }, updateRoomSettings }); setup({}, { activeRoom: { ...roomA, thinkingLevel: "medium" }, updateRoomSettings });
const { container } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />); const { container } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const select = screen.getByTestId("chat-room-thinking-level") as HTMLSelectElement; const header = container.querySelector(".chat-room-thread-header");
expect(select.value).toBe("medium"); expect(header?.querySelector("[data-testid='chat-room-thinking-level']")).toBeNull();
expect(within(select).getByRole("option", { name: "Use default" })).toBeDefined(); expect(header?.querySelector("label[for='chat-room-thinking-level']")).toBeNull();
for (const label of ["Off", "Minimal", "Low", "Medium", "High", "Very High"]) { expect(header?.querySelector(".chat-room-thinking-level-field")).toBeNull();
expect(within(select).getByRole("option", { name: label })).toBeDefined();
}
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" }); 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(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(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(screen.queryByTestId("chat-thinking-btn")).toBeNull();
}); });
it("passes attachment file list shape to room sends", async () => { it("passes attachment file list shape to room sends", async () => {

View File

@@ -1,8 +1,9 @@
// ChatView thinking-level control mount test (FN-7898). // ChatView thinking-level control mount test (FN-7898).
// //
// Asserts the Brain-icon ChatThinkingLevelControl renders ONLY in the direct-session // Asserts the Brain-icon ChatThinkingLevelControl renders in the direct-session
// (non-CLI) composer, next to the attach button, and never renders for CLI-backed // (non-CLI) composer and the active room composer next to attach. Direct chat retains its
// sessions, never in the rooms composer, and never with no active session. Also verifies // 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 // the control's displayed state tracks the active session across a session switch, and
// that it renders without layout regression at a mobile viewport. // that it renders without layout regression at a mobile viewport.
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -195,6 +196,7 @@ describe("ChatView thinking-level control (FN-7898)", () => {
_resetInitialViewportHeight(); _resetInitialViewportHeight();
vi.clearAllMocks(); vi.clearAllMocks();
mockFetchSettings.mockResolvedValue({} as Awaited<ReturnType<typeof api.fetchSettings>>); mockFetchSettings.mockResolvedValue({} as Awaited<ReturnType<typeof api.fetchSettings>>);
localStorage.setItem("fusion:chat-scope", "direct");
mockDesktopViewport(); mockDesktopViewport();
mockUseChatRooms.mockReturnValue(roomsState()); mockUseChatRooms.mockReturnValue(roomsState());
}); });
@@ -255,7 +257,7 @@ describe("ChatView thinking-level control (FN-7898)", () => {
expect(screen.queryByTestId("chat-thinking-btn")).toBeNull(); 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 }); const session = makeSession({ id: "sess-a", cliExecutorAdapterId: null });
mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session] })); mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session] }));
mockUseChatRooms.mockReturnValue(roomsState({ rooms: [roomA], activeRoom: roomA })); mockUseChatRooms.mockReturnValue(roomsState({ rooms: [roomA], activeRoom: roomA }));
@@ -263,12 +265,15 @@ describe("ChatView thinking-level control (FN-7898)", () => {
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />); await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
// The rooms composer's own attach button is present (proves the rooms const attachButton = screen.getByTestId("chat-attach-btn");
// composer rendered), but no thinking-level trigger exists anywhere. const thinkingButton = screen.getByTestId("chat-thinking-btn");
expect(screen.getAllByTestId("chat-attach-btn").length).toBeGreaterThan(0); expect(attachButton.nextElementSibling).toContainElement(thinkingButton);
expect(screen.queryByTestId("chat-thinking-btn")).toBeNull(); 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 () => { it("(d) does NOT render when there is no active session", async () => {