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:
gsxdsm
2026-08-23 21:17:55 -07:00
parent d028005ca4
commit ccf7ff114f
6 changed files with 144 additions and 30 deletions

View 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.

View File

@@ -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;

View File

@@ -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 ? (

View File

@@ -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/);
});
});

View File

@@ -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();

View File

@@ -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();
});
})