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:
7
.changeset/fn-9207-chat-focus-popover-mobile.md
Normal file
7
.changeset/fn-9207-chat-focus-popover-mobile.md
Normal 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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user