diff --git a/.changeset/fn-7916-chat-mobile-model-selector.md b/.changeset/fn-7916-chat-mobile-model-selector.md new file mode 100644 index 0000000000..c5fd842b84 --- /dev/null +++ b/.changeset/fn-7916-chat-mobile-model-selector.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix the in-chat model selector on tablet and mobile. +category: fix +dev: The brain popup's pointerdown outside-close now treats the portaled CustomModelDropdown menu (.model-combobox-dropdown--portal) as inside, so a model tap registers instead of closing the popup; ChatView.css re-anchors the mobile popover to fit the viewport. CustomModelDropdown is unchanged. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 00119e7689..d3234ade64 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -520,7 +520,8 @@ Chat view provides project-scoped conversations with agents. -- A small **Brain**-icon button next to the composer's attach button lets you change an already-created direct chat session's target and thinking level mid-conversation, without starting a new chat. Its **Model / Agent** section can switch the session to another model via the shared model picker or to a real agent from the agent list; its **Thinking level** section still lists the six thinking levels plus **Default** (clear/inherit, labeled with the current resolved default such as **Default (medium)**). Each selection persists immediately and applies starting with the session's next send. This control appears only for non-CLI Direct sessions — it is not shown for CLI-agent-backed sessions or in Chat Rooms, neither of which support this per-session retargeting control. + +- A small **Brain**-icon button next to the composer's attach button lets you change an already-created direct chat session's target and thinking level mid-conversation, without starting a new chat. Its **Model / Agent** section can switch the session to another model via the shared model picker or to a real agent from the agent list; its **Thinking level** section still lists the six thinking levels plus **Default** (clear/inherit, labeled with the current resolved default such as **Default (medium)**). Each selection persists immediately and applies starting with the session's next send, including on mobile and tablet touch viewports where the popup stays fitted to the screen. This control appears only for non-CLI Direct sessions — it is not shown for CLI-agent-backed sessions or in Chat Rooms, neither of which support this per-session retargeting control. - Full Chat and Quick Chat both consume the same streamed `/api/chat/sessions/:id/messages` response contract, and both now prefer the authoritative assistant `message` snapshot on `done` while still accumulating `text` chunks when present (so providers without incremental text streaming still render output immediately) - Final assistant messages with no text, tool calls, thinking output, attachments, or failure details render a muted **No message** placeholder instead of a blank bubble. In-progress responses still use the existing **Working…** / **Thinking…** streaming state until the run finishes. diff --git a/packages/dashboard/app/components/ChatThinkingLevelControl.tsx b/packages/dashboard/app/components/ChatThinkingLevelControl.tsx index 21025dc193..45d70347c3 100644 --- a/packages/dashboard/app/components/ChatThinkingLevelControl.tsx +++ b/packages/dashboard/app/components/ChatThinkingLevelControl.tsx @@ -79,7 +79,15 @@ export function ChatThinkingLevelControl({ useEffect(() => { if (!open) return; const handlePointerDown = (event: PointerEvent) => { - if (rootRef.current && !rootRef.current.contains(event.target as Node)) { + const target = event.target; + if (!(target instanceof Node)) return; + /* + FNXC:Chat-ModelSwitch 2026-07-12-22:35: + FN-7916: CustomModelDropdown renders its option list in a document.body portal outside rootRef. Treat that portaled menu as inside this popup so tablet/touch pointerdown does not dismiss the brain popup before the option onClick can persist the model selection. + */ + const clickedInsideRoot = rootRef.current?.contains(target); + const clickedInsidePortaledModelMenu = target instanceof Element && Boolean(target.closest(".model-combobox-dropdown--portal")); + if (!clickedInsideRoot && !clickedInsidePortaledModelMenu) { setOpen(false); } }; diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 274d2dc7f1..7583274c63 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -2003,6 +2003,8 @@ FN-7908 keeps model/agent retargeting inside the same brain popup. The widened p left: 0; bottom: calc(100% + var(--space-xs)); width: min(calc(var(--space-xl) * 15), calc(100vw - (var(--space-lg) * 2))); + max-width: calc(100vw - (var(--space-lg) * 2)); + max-inline-size: calc(100vw - (var(--space-lg) * 2)); max-height: min(calc(var(--space-xl) * 24), calc(100vh - (var(--space-xl) * 4))); overflow-y: auto; background: var(--surface); @@ -2530,9 +2532,20 @@ Queued-message banners stack above the composer input with a capped scroll area, min-height: calc(var(--space-lg) * 2.25); } + /* + FNXC:Chat-ModelSwitch 2026-07-12-22:38: + FN-7916: mobile applies a global max-width clamp to every element, so a popover positioned from the small Brain-button wrapper collapsed into an unreadable strip. Let the chat input area own the mobile containing block and inset the panel with tokenized viewport gutters so the model picker, agent list, and thinking levels stay reachable. + */ + .chat-thinking-level-root { + position: static; + } + .chat-thinking-popover { - left: 0; - width: calc(100vw - (var(--space-md) * 2)); + left: var(--space-md); + right: var(--space-md); + width: auto; + max-width: none; + max-inline-size: none; max-height: min(calc(var(--space-xl) * 20), calc(100vh - (var(--space-xl) * 5))); } diff --git a/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.portal.test.tsx b/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.portal.test.tsx new file mode 100644 index 0000000000..23b9a3a9aa --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.portal.test.tsx @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { ChatThinkingLevelControl } from "../ChatThinkingLevelControl"; +import { loadAllAppCss } from "../../test/cssFixture"; + +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" }, +]; + +const openModelPortal = async () => { + fireEvent.click(screen.getByTestId("chat-thinking-btn")); + expect(screen.getByTestId("chat-thinking-popover")).toBeInTheDocument(); + + const modelPicker = screen.getByTestId("chat-thinking-model-picker"); + fireEvent.click(within(modelPicker).getByRole("button", { name: "Model" })); + + await waitFor(() => expect(screen.getByTestId("model-combobox-portal")).toBeInTheDocument()); + return screen.getByTestId("model-combobox-portal"); +}; + +describe("ChatThinkingLevelControl with the real CustomModelDropdown portal", () => { + it("keeps the brain popup open for pointerdown inside the portaled model menu, then selects the model normally", async () => { + const onChangeModel = vi.fn(); + const portal = await openModelPortalWithRender({ onChangeModel }); + + fireEvent.pointerDown(portal); + + expect(screen.getByTestId("chat-thinking-popover")).toBeInTheDocument(); + expect(screen.getByTestId("model-combobox-portal")).toBeInTheDocument(); + + fireEvent.click(within(portal).getByText("GPT-4o")); + + expect(onChangeModel).toHaveBeenCalledWith({ modelProvider: "openai", modelId: "gpt-4o" }); + await waitFor(() => expect(screen.queryByTestId("chat-thinking-popover")).not.toBeInTheDocument()); + }); + + it("still closes the brain popup for a genuine outside pointerdown", async () => { + await openModelPortalWithRender({ onChangeModel: vi.fn() }); + + fireEvent.pointerDown(document.body); + + await waitFor(() => expect(screen.queryByTestId("chat-thinking-popover")).not.toBeInTheDocument()); + }); + + it("keeps inline agent selection, thinking-level selection, Escape, and empty states working", () => { + const onChange = vi.fn(); + const onChangeModel = vi.fn(); + const { rerender } = render( + , + ); + + 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.queryByTestId("chat-thinking-popover")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("chat-thinking-btn")); + fireEvent.click(screen.getByTestId("chat-thinking-option-high")); + expect(onChange).toHaveBeenCalledWith("high"); + expect(screen.queryByTestId("chat-thinking-popover")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("chat-thinking-btn")); + fireEvent.keyDown(screen.getByTestId("chat-thinking-btn"), { key: "Escape" }); + expect(screen.queryByTestId("chat-thinking-popover")).not.toBeInTheDocument(); + + rerender(); + fireEvent.click(screen.getByTestId("chat-thinking-btn")); + fireEvent.click(screen.getByTestId("chat-thinking-mode-model")); + expect(screen.getByTestId("chat-thinking-model-empty")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("chat-thinking-mode-agent")); + expect(screen.getByTestId("chat-thinking-agent-empty")).toBeInTheDocument(); + }); +}); + +describe("ChatThinkingLevelControl mobile popover CSS contract", () => { + it("anchors the mobile brain popup to the input area with tokenized viewport gutters", () => { + const css = loadAllAppCss(); + const mobileStart = css.indexOf("@media (max-width: 768px)", css.indexOf(".chat-thinking-btn")); + const mobileCss = css.slice(mobileStart, css.indexOf(".chat-thinking-agent-list", mobileStart)); + + expect(mobileStart).toBeGreaterThanOrEqual(0); + expect(mobileCss).toMatch(/\.chat-thinking-level-root\s*\{[^}]*position:\s*static;/); + expect(mobileCss).toMatch(/\.chat-thinking-popover\s*\{[^}]*left:\s*var\(--space-md\);[^}]*right:\s*var\(--space-md\);[^}]*width:\s*auto;[^}]*max-width:\s*none;[^}]*max-inline-size:\s*none;/); + }); +}); + +async function openModelPortalWithRender({ onChangeModel }: { onChangeModel: ReturnType }) { + render(); + return openModelPortal(); +}