FN-9207: Fix mobile chat focus popover layout

Keep memory focus controls usable across narrow chat and planner composer surfaces.

- Anchor focus popovers to full-width composer containers instead of the trigger chip.
- Bound popover height, enable scrolling, and wrap actions on constrained screens.
- Add rendered geometry coverage for narrow hosts and all memory focus states.
- Record the published dashboard fix in a patch changeset.

Files changed:
 .changeset/fn-9207-chat-focus-popover-mobile.md    |   7 +
 .../dashboard/app/components/ChatFocusSelector.css |  53 +++----
 .../app/components/TaskPlannerChatTab.css          |  10 +-
 .../chat-focus-selector.narrow-render.test.tsx     | 167 +++++++++++++++++++++
 4 files changed, 197 insertions(+), 40 deletions(-)

Fusion-Task-Id: FN-9207

Fusion-Task-Lineage: 877c4695-0621-48fc-a028-e543cc0f8fb0

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-23 20:56:45 -07:00
parent 456f7b370b
commit d028005ca4
4 changed files with 197 additions and 40 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep Chat memory Focus popovers usable on mobile and narrow chat surfaces.
category: fix
dev: Re-anchor the popover to each composer row and bound its scrollable height.

View File

@@ -1,29 +1,17 @@
/*
FNXC:ChatMemoryFocusSelector 2026-08-13:
Styling for the per-conversation memory focus selector chip + popover. Uses only
design tokens (--space-*, --color/--surface/--border/--text-muted/--accent,
--radius-*, --shadow-lg, --font-size-*, --font-weight-*) per the styling guide —
no hardcoded px (except 0), no hex/rgba. A null/empty session focus renders a
cleared "Focus" chip (never a dangling topic chip); a set topic renders an active
chip whose label is the topic. Mobile (max-width 768px) widens the popover to the
viewport minus padding so the inline input stays usable on narrow screens. The
chip matches --chat-input-control-size (the composer's send/attach control
height) so it centers with the single-line textarea.
FNXC:ChatMemoryFocusSelector 2026-08-24-03:40:
The focus popover must use a positioned, full-width composer ancestor as its containing
block, never the chip wrapper. A chip-anchored mobile breakpoint resolved its insets
against the trigger and collapsed the popover into a vertical sliver. Every future
ChatFocusSelector host must provide that positioned composer ancestor. The bounded,
scrollable box keeps its title and controls available when the chat pane clips overflow.
FNXC:ChatMemoryFocusSelector 2026-08-21-13:35:
RUFU-146 review (PRRT_kwDOSA-8Y86a7RZo): raw literals replaced with existing
semantic tokens — --btn-border-width (borders), --transition-fast
(chip transition), --opacity-disabled (disabled chip), --focus-ring-strong
(focus-visible, previously an invalid `outline: 2px solid var(--focus-ring)`
declaration since --focus-ring is a box-shadow token), calc(var(--space-xl) * 8)
(12rem label cap), --z-popover (popover layer), --line-height-normal (help text).
The 768px breakpoint stays literal by design: --mobile-breakpoint is documented
in styles.css as documentation-only (custom properties cannot appear in
@media conditions) and every other component CSS uses the same literal.
The chip matches --chat-input-control-size (the composer's send/attach control height)
so it centers with the single-line textarea. Styling uses only existing design tokens.
*/
.chat-focus-root {
position: relative;
position: static;
flex: none;
}
@@ -68,11 +56,13 @@ in styles.css as documentation-only (custom properties cannot appear in
.chat-focus-popover {
position: absolute;
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));
inset-inline-start: var(--space-md);
inset-inline-end: auto;
inset-block-end: calc(100% + var(--space-xs));
inline-size: min(calc(var(--space-xl) * 15), calc(100% - (var(--space-md) * 2)));
max-inline-size: calc(100% - (var(--space-md) * 2));
max-block-size: min(calc(var(--space-xl) * 16), calc(100vh - (var(--space-xl) * 6)));
overflow-y: auto;
padding: var(--space-sm);
background: var(--surface);
border: var(--btn-border-width) solid var(--border);
@@ -102,6 +92,7 @@ in styles.css as documentation-only (custom properties cannot appear in
.chat-focus-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
@@ -109,13 +100,3 @@ in styles.css as documentation-only (custom properties cannot appear in
.chat-focus-clear {
flex: 0 0 auto;
}
@media (max-width: 768px) {
.chat-focus-popover {
left: var(--space-md);
right: var(--space-md);
width: auto;
max-width: none;
max-inline-size: none;
}
}

View File

@@ -236,12 +236,14 @@ Task Chat keeps model and thinking controls reachable beside the composer, reusi
}
/*
FNXC:ChatMemoryFocus 2026-08-13:
Spacing row holding the per-conversation memory focus chip above the planner
composer. The chip inherits its control-size token from the composer below so
it aligns with the send/stop buttons.
FNXC:ChatMemoryFocusSelector 2026-08-24-03:40:
The planner focus popover must be contained by this full-width composer row, never the
chip or the full chat pane. The chip-anchored mobile override collapsed the popover into
a sliver; a future ChatFocusSelector host must likewise provide a positioned full-width
composer ancestor. The chip inherits its control-size token from the composer below.
*/
.task-planner-chat-focus-row {
position: relative;
display: flex;
flex: 0 0 auto;
align-items: center;

View File

@@ -0,0 +1,167 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { ChatFocusSelector } from "../ChatFocusSelector";
import { loadAllAppCss } from "../../test/cssFixture";
vi.mock("../../api", () => ({
updateChatSession: vi.fn(),
}));
import { updateChatSession } from "../../api";
/*
FNXC:ChatMemoryFocusSelector 2026-08-24-03:40:
The focus popover must use its full-width composer row as its containing block. These
rendered host chains prevent a chip-sized positioned wrapper from collapsing the mobile
popover into a vertical sliver.
*/
const appCss = loadAllAppCss();
const mockUpdateChatSession = vi.mocked(updateChatSession);
const focusStates = [null, "", "all", "*", "a deliberately long memory focus topic for the narrow two-button state"];
afterEach(() => {
cleanup();
document.head.querySelector("style[data-chat-focus-css]")?.remove();
});
beforeEach(() => {
mockUpdateChatSession.mockReset();
});
function renderWithCss(ui: JSX.Element) {
const style = document.createElement("style");
style.dataset.chatFocusCss = "true";
style.textContent = appCss;
document.head.appendChild(style);
return render(ui);
}
function nearestPositionedAncestor(element: HTMLElement): HTMLElement | null {
let current = element.parentElement;
while (current) {
if (getComputedStyle(current).position !== "static") return current;
current = current.parentElement;
}
return null;
}
function FocusSelector({ memoryFocus = null }: { memoryFocus?: string | null }) {
return (
<ChatFocusSelector
sessionId="SES-1"
memoryFocus={memoryFocus}
onPersist={() => undefined}
addToast={() => undefined}
/>
);
}
function openPopover() {
fireEvent.click(screen.getByTestId("chat-focus-chip"));
return screen.getByTestId("chat-focus-popover");
}
function expectPopoverGeometry(anchorClass: string) {
const popover = openPopover();
const root = screen.getByTestId("chat-focus-root");
const positionedAncestor = nearestPositionedAncestor(popover);
const popoverStyle = getComputedStyle(popover);
expect(positionedAncestor).toHaveClass(anchorClass);
expect(positionedAncestor).not.toBe(root);
expect(positionedAncestor).not.toHaveClass("task-planner-chat");
expect(getComputedStyle(root).position).toBe("static");
expect(popoverStyle.maxBlockSize || popoverStyle.maxHeight).not.toBe("");
expect(popoverStyle.overflowY).toBe("auto");
}
function renderChatHost({ narrow, memoryFocus }: { narrow: boolean; memoryFocus: string | null }) {
return renderWithCss(
<div className={`chat-view${narrow ? " chat-view--narrow" : ""}`}>
<div className="chat-thread">
<div className="chat-input-area">
<div className="chat-input-row"><FocusSelector memoryFocus={memoryFocus} /></div>
</div>
</div>
</div>,
);
}
function renderPlannerHost(memoryFocus: string | null) {
return renderWithCss(
<div className="task-planner-chat">
<div className="task-planner-chat-focus-row"><FocusSelector memoryFocus={memoryFocus} /></div>
<div className="task-planner-chat-composer" />
</div>,
);
}
describe("ChatFocusSelector narrow host geometry", () => {
it.each([false, true])("anchors ChatView focus popovers to the composer area when narrow=%s", (narrow) => {
renderChatHost({ narrow, memoryFocus: null });
expectPopoverGeometry("chat-input-area");
});
it("anchors the planner focus popover to its composer row instead of the pane", () => {
renderPlannerHost(null);
expectPopoverGeometry("task-planner-chat-focus-row");
});
it.each(focusStates)("keeps the bounded ChatView popover usable for memoryFocus=%j", (memoryFocus) => {
renderChatHost({ narrow: true, memoryFocus });
expectPopoverGeometry("chat-input-area");
if (memoryFocus && memoryFocus !== "all" && memoryFocus !== "*") {
expect(screen.getByTestId("chat-focus-save")).toBeInTheDocument();
expect(screen.getByTestId("chat-focus-clear")).toBeInTheDocument();
expect(getComputedStyle(screen.getByTestId("chat-focus-save").parentElement as HTMLElement).flexWrap).toBe("wrap");
} else {
expect(screen.getByTestId("chat-focus-save")).toBeInTheDocument();
expect(screen.queryByTestId("chat-focus-clear")).not.toBeInTheDocument();
}
});
it("keeps the bounded planner popover usable for every focus state", () => {
for (const memoryFocus of focusStates) {
renderPlannerHost(memoryFocus);
expectPopoverGeometry("task-planner-chat-focus-row");
cleanup();
}
});
it("dismisses the popover by keyboard and pointer without changing its host geometry", () => {
renderChatHost({ narrow: true, memoryFocus: null });
expectPopoverGeometry("chat-input-area");
fireEvent.keyDown(screen.getByTestId("chat-focus-input"), { key: "Escape" });
expect(screen.queryByTestId("chat-focus-popover")).not.toBeInTheDocument();
openPopover();
fireEvent.pointerDown(document.body);
expect(screen.queryByTestId("chat-focus-popover")).not.toBeInTheDocument();
});
it("disables both actions while a focus update is saving", () => {
mockUpdateChatSession.mockReturnValueOnce(new Promise(() => undefined));
renderChatHost({ narrow: true, memoryFocus: "active topic" });
openPopover();
fireEvent.click(screen.getByTestId("chat-focus-save"));
expect(screen.getByTestId("chat-focus-save")).toBeDisabled();
expect(screen.getByTestId("chat-focus-clear")).toBeDisabled();
});
it("does not render a popover for a missing session", () => {
renderWithCss(
<div className="chat-input-area">
<div className="chat-input-row">
<ChatFocusSelector sessionId={null} memoryFocus={null} onPersist={() => undefined} addToast={() => undefined} />
</div>
</div>,
);
expect(screen.getByTestId("chat-focus-chip")).toBeDisabled();
expect(screen.queryByTestId("chat-focus-popover")).not.toBeInTheDocument();
});
});