From ccf7ff114f3da1fad5ffcfb78437ddc2cf75a6d7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 23 Aug 2026 21:17:55 -0700 Subject: [PATCH] FN-9208: remove cleared chat focus text Make cleared memory-focus controls icon-only while preserving their accessible name. - Render the focus target without redundant text when no topic is selected. - Keep the cleared chip square across desktop and mobile layouts. - Cover direct chat, planner chat, cleared values, and disabled controls. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-9208-chat-focus-icon-only.md | 7 +++ .../dashboard/app/components/ChatFocusSelector.css | 15 +++++- .../dashboard/app/components/ChatFocusSelector.tsx | 19 +++---- .../__tests__/ChatView.focus-chip.test.tsx | 59 ++++++++++++++++++++ .../__tests__/TaskPlannerChatTab.test.tsx | 12 +++++ .../__tests__/chat-focus-selector.test.tsx | 62 +++++++++++++++------- 6 files changed, 144 insertions(+), 30 deletions(-) Fusion-Task-Id: FN-9208 Fusion-Task-Lineage: a48faaf7-35e6-4f57-bb47-02aa3febb474 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-9208-chat-focus-icon-only.md | 7 +++ .../app/components/ChatFocusSelector.css | 15 ++++- .../app/components/ChatFocusSelector.tsx | 19 +++--- .../__tests__/ChatView.focus-chip.test.tsx | 59 ++++++++++++++++++ .../__tests__/TaskPlannerChatTab.test.tsx | 12 ++++ .../__tests__/chat-focus-selector.test.tsx | 62 +++++++++++++------ 6 files changed, 144 insertions(+), 30 deletions(-) create mode 100644 .changeset/fn-9208-chat-focus-icon-only.md create mode 100644 packages/dashboard/app/components/__tests__/ChatView.focus-chip.test.tsx diff --git a/.changeset/fn-9208-chat-focus-icon-only.md b/.changeset/fn-9208-chat-focus-icon-only.md new file mode 100644 index 0000000000..96d9d112bd --- /dev/null +++ b/.changeset/fn-9208-chat-focus-icon-only.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Chat memory-focus button is icon-only until a topic is set. +category: feature +dev: ChatFocusSelector no longer renders the cleared chat.focusNone label. diff --git a/packages/dashboard/app/components/ChatFocusSelector.css b/packages/dashboard/app/components/ChatFocusSelector.css index 1f08d80bb7..e36e986357 100644 --- a/packages/dashboard/app/components/ChatFocusSelector.css +++ b/packages/dashboard/app/components/ChatFocusSelector.css @@ -8,6 +8,11 @@ scrollable box keeps its title and controls available when the chat pane clips o 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. + +FNXC:ChatMemoryFocusSelector 2026-08-24-03:59: +A cleared focus is an icon-only control: its aria-label and title supply the accessible +name, while a selected topic retains its visible chip label. The modifier removes label +padding and gap so the cleared control stays square at every breakpoint. */ .chat-focus-root { @@ -45,8 +50,14 @@ so it centers with the single-line textarea. Styling uses only existing design t border-color: var(--accent); } -.chat-focus-chip-topic, -.chat-focus-chip-label { +.chat-focus-chip--icon-only { + inline-size: var(--chat-input-control-size, 2.25rem); + padding: 0; + gap: 0; + justify-content: center; +} + +.chat-focus-chip-topic { max-inline-size: calc(var(--space-xl) * 8); overflow: hidden; text-overflow: ellipsis; diff --git a/packages/dashboard/app/components/ChatFocusSelector.tsx b/packages/dashboard/app/components/ChatFocusSelector.tsx index 5469fa2cbd..2472d95d9c 100644 --- a/packages/dashboard/app/components/ChatFocusSelector.tsx +++ b/packages/dashboard/app/components/ChatFocusSelector.tsx @@ -13,9 +13,14 @@ empty -> null and bumps updatedAt) so it survives reconnect. Recall is then scoped to that topic as a WITHIN-project read filter (searchProjectMemory -> backend.search -> Stash REST topic param) NEVER a client-side / post-query in-memory filter, and cross-project A/B isolation is -never weakened. A null/absent focus shows a cleared state (a "focus" chip to -set one), never a dangling chip, and an empty value or "all"/"*" collapses to -whole-project scope. Capture stays write-anywhere and topic-agnostic. +never weakened. A null/absent focus shows an icon-only chip; its aria-label and title preserve an +accessible name without consuming composer width. A set topic remains visible on the +chip, and an empty value or "all"/"*" collapses to whole-project scope. Capture stays +write-anywhere and topic-agnostic. + +FNXC:ChatMemoryFocusSelector 2026-08-24-03:59: +The cleared state must not render the redundant "Focus" word. It uses the button's +aria-label and title as its accessible name while a selected topic remains visible. */ export interface ChatFocusSelectorProps { @@ -133,7 +138,7 @@ export function ChatFocusSelector({
{open && sessionId ? ( diff --git a/packages/dashboard/app/components/__tests__/ChatView.focus-chip.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.focus-chip.test.tsx new file mode 100644 index 0000000000..d458120451 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ChatView.focus-chip.test.tsx @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; +import React from "react"; +import { screen } from "@testing-library/react"; +import { ChatView } from "../ChatView"; +import { + activeSessionFixture, + defaultChatState, + installChatViewEnv, + renderChatDetailWithAct, + setupMockChat, + setupMockRooms, +} from "./ChatView.test-harness"; + +// Factories stay inline: importing the shared harness from a factory creates a TDZ cycle. +vi.mock("../../hooks/useChat"); +vi.mock("../../hooks/useChatRooms"); +vi.mock("../../hooks/useChatUnread", () => ({ + useChatUnread: () => ({ isUnread: () => false, markRead: vi.fn() }), +})); +vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => ({ + ...(await importOriginal()), + useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }), +})); +vi.mock("../CustomModelDropdown", () => ({ CustomModelDropdown: () => null })); +vi.mock("lucide-react", async (importOriginal) => ({ + ...(await importOriginal()), + Target: (props: React.SVGProps) => React.createElement("svg", props), +})); +vi.mock("../../api", () => ({ + fetchSettings: vi.fn().mockResolvedValue({}), + fetchChatSession: vi.fn().mockResolvedValue({ session: { memoryFocus: null } }), + fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }), + fetchAgents: vi.fn().mockResolvedValue([]), + fetchDiscoveredSkills: vi.fn().mockResolvedValue([]), + fetchTasks: vi.fn().mockResolvedValue([]), + searchFiles: vi.fn().mockResolvedValue({ files: [] }), + updateChatSession: vi.fn(), +})); + +installChatViewEnv(); + +describe("ChatView memory focus chip", () => { + it("keeps a cleared direct-chat focus control icon-only with its accessible name", async () => { + const session = { ...activeSessionFixture, id: "session-focus", title: "Focus-free chat" }; + setupMockChat({ + ...defaultChatState, + activeSession: session, + sessions: [session], + filteredSessions: [session], + }); + setupMockRooms(); + + await renderChatDetailWithAct(); + + const chip = screen.getByRole("button", { name: "Memory focus topic" }); + expect(chip.textContent?.trim()).toBe(""); + expect(chip).not.toHaveTextContent(/Focus/); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx index 405428d854..81734098c7 100644 --- a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx @@ -217,6 +217,18 @@ describe("TaskPlannerChatTab", () => { restoreMetricDescriptor("clientHeight", originalClientHeightDescriptor); }); + it("keeps a cleared planner memory-focus control icon-only with its accessible name", async () => { + const plannerSession = makePlannerSession({ memoryFocus: null }); + mockFetchTaskPlannerChatSession.mockResolvedValue({ session: plannerSession }); + mockFetchChatSession.mockResolvedValue({ session: plannerSession }); + renderPlannerChat(); + + const chip = await screen.findByRole("button", { name: "Memory focus topic" }); + expect(chip.closest(".task-planner-chat-focus-row")).toBeTruthy(); + expect(chip.textContent?.trim()).toBe(""); + expect(chip).not.toHaveTextContent(/Focus/); + }); + it("looks up an existing task-scoped planner session and renders the starter-prompt empty state", async () => { renderPlannerChat(); diff --git a/packages/dashboard/app/components/__tests__/chat-focus-selector.test.tsx b/packages/dashboard/app/components/__tests__/chat-focus-selector.test.tsx index 27210aab18..86b8da0ea5 100644 --- a/packages/dashboard/app/components/__tests__/chat-focus-selector.test.tsx +++ b/packages/dashboard/app/components/__tests__/chat-focus-selector.test.tsx @@ -1,7 +1,8 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; import { ChatFocusSelector } from "../ChatFocusSelector"; +import { loadAllAppCss } from "../../test/cssFixture"; vi.mock("../../api", () => ({ updateChatSession: vi.fn(), @@ -10,6 +11,7 @@ vi.mock("../../api", () => ({ import { updateChatSession } from "../../api"; const mockUpdateChatSession = vi.mocked(updateChatSession); +const originalInnerWidthDescriptor = Object.getOwnPropertyDescriptor(window, "innerWidth"); /* FNXC:ChatMemoryFocusSelectorTest 2026-08-13: @@ -43,31 +45,57 @@ describe("ChatFocusSelector", () => { addToast.mockReset(); }); + afterEach(() => { + document.head.querySelector("[data-testid='chat-focus-selector-css']")?.remove(); + if (originalInnerWidthDescriptor) { + Object.defineProperty(window, "innerWidth", originalInnerWidthDescriptor); + } + }); + it("shows the active topic on the chip when the session has a focus", () => { renderSelector({ memoryFocus: "auth-northstar" }); expect(screen.getByTestId("chat-focus-chip")).toHaveTextContent("auth-northstar"); }); - it("shows a cleared/absent state when the session focus is null (no dangling topic chip)", () => { - renderSelector({ memoryFocus: null }); - const chip = screen.getByTestId("chat-focus-chip"); - expect(chip).toHaveTextContent("Focus"); - expect(chip).not.toHaveTextContent("auth-northstar"); + it.each([null, "", "all", "*"])("renders memoryFocus=%j as an icon-only whole-project control", (memoryFocus) => { + renderSelector({ memoryFocus }); + const chip = screen.getByRole("button", { name: "Memory focus topic" }); + + expect(chip.textContent?.trim()).toBe(""); + expect(chip).not.toHaveTextContent(/Focus/); + expect(chip.querySelector("svg")).toBeTruthy(); + expect(chip).toHaveClass("chat-focus-chip--icon-only"); }); - it("treats an empty-string and whole-project-collapse focus as cleared", () => { - renderSelector({ memoryFocus: "" }); - expect(screen.getByTestId("chat-focus-chip")).toHaveTextContent("Focus"); - // "all" and "*" collapse to whole-project scope on display. - const { unmount } = renderSelector({ memoryFocus: "all" }); - unmount(); - void renderSelector({ memoryFocus: "*" }); - expect(screen.getAllByTestId("chat-focus-chip")[0]).toHaveTextContent("Focus"); + it("keeps the disabled no-session control icon-only and accessible", () => { + renderSelector({ sessionId: null }); + const chip = screen.getByRole("button", { name: "Memory focus topic" }); + + expect(chip).toBeDisabled(); + expect(chip.textContent?.trim()).toBe(""); + expect(chip).not.toHaveTextContent(/Focus/); }); it("preserves a set topic verbatim (whitespace-trimmed active chip)", () => { renderSelector({ memoryFocus: " spaced topic " }); - expect(screen.getByTestId("chat-focus-chip")).toHaveTextContent("spaced topic"); + const chip = screen.getByTestId("chat-focus-chip"); + expect(chip).toHaveTextContent("spaced topic"); + expect(chip).toHaveClass("chat-focus-chip--active"); + }); + + it.each(["desktop", "mobile"])("keeps the icon-only chip square without label spacing at the %s cascade", (viewport) => { + Object.defineProperty(window, "innerWidth", { value: viewport === "mobile" ? 768 : 1024, configurable: true }); + const style = document.createElement("style"); + style.dataset.testid = "chat-focus-selector-css"; + style.textContent = loadAllAppCss(); + document.head.appendChild(style); + renderSelector({ memoryFocus: null }); + + const computed = getComputedStyle(screen.getByTestId("chat-focus-chip")); + expect(computed.paddingLeft).toBe("0px"); + expect(computed.paddingRight).toBe("0px"); + expect(computed.gap).toBe("0px"); + expect(computed.inlineSize).toContain("var(--chat-input-control-size"); }); it("persists a typed topic via updateChatSession and reflects the persisted state", async () => { @@ -107,8 +135,4 @@ describe("ChatFocusSelector", () => { await waitFor(() => expect(mockUpdateChatSession).toHaveBeenCalledWith("SES-1", { memoryFocus: null }, "proj-123")); }); - it("is hidden/disabled when there is no session id", () => { - renderSelector({ sessionId: null }); - expect(screen.getByTestId("chat-focus-chip")).toBeDisabled(); - }); }) \ No newline at end of file