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:
7
.changeset/fn-8030-room-thinking-composer.md
Normal file
7
.changeset/fn-8030-room-thinking-composer.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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()} />);
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user