From 313956df5ac8577ffe84fb2cbd36ff0611fe8ed6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 12 Jul 2026 22:49:51 -0700 Subject: [PATCH] FN-7916: fix chat model selector dismissing before selection on mobile/tablet Fix the in-chat Brain popup's model picker on touch viewports: pointerdown outside-close was treating the portaled CustomModelDropdown menu as outside the popup, dismissing it before the tap could register a model selection, and the mobile popover was mis-anchored off-screen. - ChatThinkingLevelControl: treat pointerdown targets inside the portaled `.model-combobox-dropdown--portal` menu as inside the popup so touch taps select the model instead of closing the popup first - ChatView.css: clamp the popover to the viewport width via max-width/max-inline-size, and on mobile anchor it to the chat input area with tokenized left/right gutters instead of a collapsing left:0 width - Add a portal-aware regression test covering pointerdown-inside-portal selection, genuine outside-pointerdown close, and the mobile CSS anchoring contract - Add changeset (patch) and update dashboard guide docs to describe mobile/tablet behavior Files changed: .changeset/fn-7916-chat-mobile-model-selector.md | 7 ++ docs/dashboard-guide.md | 3 +- .../app/components/ChatThinkingLevelControl.tsx | 10 ++- packages/dashboard/app/components/ChatView.css | 17 +++- .../ChatThinkingLevelControl.portal.test.tsx | 97 ++++++++++++++++++++++ 5 files changed, 130 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-7916 Fusion-Task-Lineage: bcc2d9ad-0be6-4e23-b643-eebf93aae3c6 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7916-chat-mobile-model-selector.md | 7 ++ docs/dashboard-guide.md | 3 +- .../components/ChatThinkingLevelControl.tsx | 10 +- .../dashboard/app/components/ChatView.css | 17 +++- .../ChatThinkingLevelControl.portal.test.tsx | 97 +++++++++++++++++++ 5 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 .changeset/fn-7916-chat-mobile-model-selector.md create mode 100644 packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.portal.test.tsx 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(); +}