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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7916-chat-mobile-model-selector.md
Normal file
7
.changeset/fn-7916-chat-mobile-model-selector.md
Normal file
@@ -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.
|
||||
@@ -520,7 +520,8 @@ Chat view provides project-scoped conversations with agents.
|
||||
<!-- FNXC:Chat-ThinkingLevel 2026-07-12-19:30: FN-7898 closes the gap left by the create-time-only picker above: an existing session's reasoning-effort level can now be changed mid-conversation from the composer itself. -->
|
||||
<!-- FNXC:Chat-ThinkingLevel 2026-07-12-20:13: FN-7905: chat thinking-level Default entries display the resolved project/global default in both the New Chat picker and in-chat Brain popup; choosing Default still clears the per-session override. -->
|
||||
<!-- FNXC:Chat-ModelSwitch 2026-07-12-00:00: FN-7908 extends that same Brain popup rather than adding another composer button, so active non-CLI Direct chats can retarget to a model pair or real agent mid-conversation and the next send resolves the updated session target. -->
|
||||
- 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.
|
||||
<!-- FNXC:Chat-ModelSwitch 2026-07-12-22:44: FN-7916 keeps the Brain popup usable on mobile/tablet touch devices: the portaled shared model picker is treated as part of the popup for selection, and the popup is viewport-fitted instead of anchored off-screen. -->
|
||||
- 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)
|
||||
<!-- FNXC:ChatEmptyMessage 2026-07-10-00:00: Empty final assistant responses can be legitimate provider output (for example a Grok CLI run ending without text). Document the shared Chat/Planner Chat behavior so operators see "No message" instead of interpreting a blank bubble as a rendering failure. -->
|
||||
- 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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
<ChatThinkingLevelControl level={null} onChange={onChange} onChangeModel={onChangeModel} models={models} agents={agents} />,
|
||||
);
|
||||
|
||||
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(<ChatThinkingLevelControl level={null} onChange={onChange} onChangeModel={onChangeModel} models={[]} agents={[]} />);
|
||||
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<typeof vi.fn> }) {
|
||||
render(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={onChangeModel} models={models} agents={agents} />);
|
||||
return openModelPortal();
|
||||
}
|
||||
Reference in New Issue
Block a user