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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-9208-chat-focus-icon-only.md
Normal file
7
.changeset/fn-9208-chat-focus-icon-only.md
Normal file
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
<div className="chat-focus-root" ref={rootRef} data-testid="chat-focus-root">
|
||||
<button
|
||||
type="button"
|
||||
className={`chat-focus-chip${hasTopic ? " chat-focus-chip--active" : ""}`}
|
||||
className={`chat-focus-chip${hasTopic ? " chat-focus-chip--active" : " chat-focus-chip--icon-only"}`}
|
||||
data-testid="chat-focus-chip"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
@@ -146,11 +151,7 @@ export function ChatFocusSelector({
|
||||
}}
|
||||
>
|
||||
<Target size={14} aria-hidden="true" />
|
||||
{hasTopic ? (
|
||||
<span className="chat-focus-chip-topic">{focusedTopic}</span>
|
||||
) : (
|
||||
<span className="chat-focus-chip-label">{t("chat.focusNone", "Focus")}</span>
|
||||
)}
|
||||
{hasTopic ? <span className="chat-focus-chip-topic">{focusedTopic}</span> : null}
|
||||
</button>
|
||||
|
||||
{open && sessionId ? (
|
||||
|
||||
@@ -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<typeof import("../../hooks/useNavigationHistory")>()),
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../CustomModelDropdown", () => ({ CustomModelDropdown: () => null }));
|
||||
vi.mock("lucide-react", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("lucide-react")>()),
|
||||
Target: (props: React.SVGProps<SVGSVGElement>) => 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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const chip = screen.getByRole("button", { name: "Memory focus topic" });
|
||||
expect(chip.textContent?.trim()).toBe("");
|
||||
expect(chip).not.toHaveTextContent(/Focus/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
})
|
||||
Reference in New Issue
Block a user