diff --git a/.changeset/fn-9207-chat-focus-popover-mobile.md b/.changeset/fn-9207-chat-focus-popover-mobile.md new file mode 100644 index 0000000000..e4f288618c --- /dev/null +++ b/.changeset/fn-9207-chat-focus-popover-mobile.md @@ -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. diff --git a/packages/dashboard/app/components/ChatFocusSelector.css b/packages/dashboard/app/components/ChatFocusSelector.css index d384c2e461..1f08d80bb7 100644 --- a/packages/dashboard/app/components/ChatFocusSelector.css +++ b/packages/dashboard/app/components/ChatFocusSelector.css @@ -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; - } -} \ No newline at end of file diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.css b/packages/dashboard/app/components/TaskPlannerChatTab.css index 6b62d0a0fc..286905f65b 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.css +++ b/packages/dashboard/app/components/TaskPlannerChatTab.css @@ -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; diff --git a/packages/dashboard/app/components/__tests__/chat-focus-selector.narrow-render.test.tsx b/packages/dashboard/app/components/__tests__/chat-focus-selector.narrow-render.test.tsx new file mode 100644 index 0000000000..788b9494cb --- /dev/null +++ b/packages/dashboard/app/components/__tests__/chat-focus-selector.narrow-render.test.tsx @@ -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 ( + 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( +
+
+
+
+
+
+
, + ); +} + +function renderPlannerHost(memoryFocus: string | null) { + return renderWithCss( +
+
+
+
, + ); +} + +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( +
+
+ undefined} addToast={() => undefined} /> +
+
, + ); + + expect(screen.getByTestId("chat-focus-chip")).toBeDisabled(); + expect(screen.queryByTestId("chat-focus-popover")).not.toBeInTheDocument(); + }); +});