From d028005ca4a744df470440c7efef2366d096dc9b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 23 Aug 2026 20:56:45 -0700 Subject: [PATCH 1/4] 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) --- .../fn-9207-chat-focus-popover-mobile.md | 7 + .../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(-) create mode 100644 .changeset/fn-9207-chat-focus-popover-mobile.md create mode 100644 packages/dashboard/app/components/__tests__/chat-focus-selector.narrow-render.test.tsx 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(); + }); +}); From ccf7ff114f3da1fad5ffcfb78437ddc2cf75a6d7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 23 Aug 2026 21:17:55 -0700 Subject: [PATCH 2/4] 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 From 68c466a6abdb82bb4babd399f57ed39a4184867f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 23 Aug 2026 21:47:40 -0700 Subject: [PATCH 3/4] FN-9209: Gate chat focus behind an experimental flag Make per-conversation chat memory focus opt-in while preserving stored focus values for compatibility. - Add the chatFocus experimental setting, exports, documentation, and release changeset. - Hide and disable focus controls and commands across chat and task-planner composers when the flag is off. - Apply persisted focus scopes to memory recall only when enabled, with regression coverage for both flag states. Files changed: .changeset/fn-9209-chat-focus-experimental-flag.md | 7 ++ docs/memory-backend-integration.md | 6 +- docs/settings-reference.md | 3 +- .../core/src/__tests__/settings-defaults.test.ts | 9 +- packages/core/src/config/experimental-features.ts | 7 ++ packages/core/src/index.gate.ts | 2 +- packages/core/src/index.ts | 2 +- packages/core/src/types.ts | 7 ++ packages/dashboard/app/components/ChatView.tsx | 37 ++++--- .../dashboard/app/components/SettingsModal.tsx | 1 + .../app/components/TaskPlannerChatTab.tsx | 62 ++++++++--- .../__tests__/ChatView.chat-focus-flag.test.tsx | 122 +++++++++++++++++++++ .../__tests__/ChatView.focus-chip.test.tsx | 2 +- .../TaskPlannerChatTab.chat-focus-flag.test.tsx | 108 ++++++++++++++++++ .../__tests__/TaskPlannerChatTab.test.tsx | 5 +- .../app/components/__tests__/chat-commands.test.ts | 7 +- .../app/components/__tests__/focus-command.test.ts | 13 ++- packages/dashboard/app/components/chat-commands.ts | 11 ++ .../chat-memory-focus-reachability.test.ts | 16 ++- packages/dashboard/src/chat.ts | 30 ++--- 20 files changed, 394 insertions(+), 63 deletions(-) Fusion-Task-Id: FN-9209 Fusion-Task-Lineage: d58296be-a5f9-4b8c-bf4a-03f7f9100efe Co-authored-by: Fusion (runfusion.ai) --- .../fn-9209-chat-focus-experimental-flag.md | 7 + docs/memory-backend-integration.md | 6 +- docs/settings-reference.md | 3 +- .../src/__tests__/settings-defaults.test.ts | 9 +- .../core/src/config/experimental-features.ts | 7 + packages/core/src/index.gate.ts | 2 +- packages/core/src/index.ts | 2 +- packages/core/src/types.ts | 7 + .../dashboard/app/components/ChatView.tsx | 37 +++--- .../app/components/SettingsModal.tsx | 1 + .../app/components/TaskPlannerChatTab.tsx | 62 ++++++--- .../ChatView.chat-focus-flag.test.tsx | 122 ++++++++++++++++++ .../__tests__/ChatView.focus-chip.test.tsx | 2 +- ...askPlannerChatTab.chat-focus-flag.test.tsx | 108 ++++++++++++++++ .../__tests__/TaskPlannerChatTab.test.tsx | 5 +- .../__tests__/chat-commands.test.ts | 7 +- .../__tests__/focus-command.test.ts | 13 +- .../dashboard/app/components/chat-commands.ts | 11 ++ .../chat-memory-focus-reachability.test.ts | 16 ++- packages/dashboard/src/chat.ts | 30 ++--- 20 files changed, 394 insertions(+), 63 deletions(-) create mode 100644 .changeset/fn-9209-chat-focus-experimental-flag.md create mode 100644 packages/dashboard/app/components/__tests__/ChatView.chat-focus-flag.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/TaskPlannerChatTab.chat-focus-flag.test.tsx diff --git a/.changeset/fn-9209-chat-focus-experimental-flag.md b/.changeset/fn-9209-chat-focus-experimental-flag.md new file mode 100644 index 0000000000..247e497af8 --- /dev/null +++ b/.changeset/fn-9209-chat-focus-experimental-flag.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Make per-conversation chat memory focus an opt-in experimental feature. +category: feature +dev: Use experimentalFeatures.chatFocus to enable the composer chip, /focus command, and recall scoping. diff --git a/docs/memory-backend-integration.md b/docs/memory-backend-integration.md index 2595bac139..db0d643fcf 100644 --- a/docs/memory-backend-integration.md +++ b/docs/memory-backend-integration.md @@ -70,8 +70,10 @@ are distinguishable by the discriminator tag. ## 5. Per-conversation memory focus (read-time scoping) -Fusion implements **conversation focus** so a recall hit is scoped to the conversation that -produced it. The focus is persisted per chat session via the schema migration +Fusion implements **conversation focus** as an opt-in feature. Enable +`experimentalFeatures.chatFocus` in **Settings → Experimental Features** to show its composer +control and apply its recall scope; the flag is default off, and persisted focus values are inert +until it is enabled. The focus is persisted per chat session via the schema migration **`0059_chat_session_memory_focus.sql`** (`SCHEMA_BASELINE_VERSION` = `0059`), which adds a `memory_focus` column to the chat-session table. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 5dcfc759ad..d9a24ea0da 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -213,7 +213,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio | `researchGlobalMaxSearchResults` | `number` | `undefined` | Maximum search results per provider query. | | `researchGlobalFetchTimeoutMs` | `number` | `30000` | Timeout for individual HTTP fetches in milliseconds. | | `researchGlobalUserAgent` | `string` | `"FusionResearchBot/1.0"` | User-Agent header for HTTP requests made by research providers. | -| `experimentalFeatures` | `Record` | `{}` | Global-scoped experimental feature flags. Includes `experimentalFeatures.researchView`, which gates all Research surfaces and tools (dashboard view, engine task-session tools, and CLI `fn_research_*` tools); `experimentalFeatures.evalsView`, which gates Evals surfaces (dashboard view, Settings → Scheduled Evals, and scheduled-eval cron execution); and default-off `experimentalFeatures.ideationView`, which gates the top-level Ideation view (desktop sidebar/Header fallback and mobile More only). | +| `experimentalFeatures` | `Record` | `{}` | Global-scoped experimental feature flags. Includes `experimentalFeatures.researchView`, which gates all Research surfaces and tools (dashboard view, engine task-session tools, and CLI `fn_research_*` tools); `experimentalFeatures.evalsView`, which gates Evals surfaces (dashboard view, Settings → Scheduled Evals, and scheduled-eval cron execution); default-off `experimentalFeatures.ideationView`, which gates the top-level Ideation view (desktop sidebar/Header fallback and mobile More only); and default-off `experimentalFeatures.chatFocus`, which gates the chat composer Focus chip, `/focus` slash command, and server-side recall scoping. Persisted chat focus is inert while this flag is off. | | `remoteAccess` | `RemoteAccessSettings` | `{ activeProvider: null, providers: {...}, tokenStrategy: {...}, lifecycle: {...} }` | Global-scoped remote access provider + token strategy configuration used by Remote Access routes and tunnel lifecycle controls. | | `mcpServers` | `McpServersSettings` | `{ enabled: false, servers: [] }` | Global MCP server declarations shared across projects. Project `mcpServers` can enable/disable the effective set, override a same-named global server, or disable a global server with a same-named `enabled:false` entry. Sensitive env/header/token values must be `{ secretRef, scope }` references to Fusion-managed secrets, never plaintext. | | `worktrunk` | `WorktrunkSettings` | `{ enabled: false, binaryPath: undefined, installedBinaryPath: undefined, onFailure: "fail" }` | Global defaults for worktrunk integration. Merged field-by-field with project `worktrunk` values; project values override global values for matching fields. | @@ -1706,6 +1706,7 @@ Common built-in dashboard/runtime flags include: - `researchView` - `evalsView` (gates Evals dashboard view, Settings → Scheduled Evals section, and scheduled-eval cron execution) - `ideationView` (default off; gates the top-level Ideation view, which is mobile More-only and replaces the Command Center Ideation tab) +- `chatFocus` (default off; gates the chat Focus chip, `/focus` command, and server-side per-conversation recall scoping. A persisted focus topic is inert until enabled in Settings → Experimental Features.) - `workflowGraphExecutor` (enables the workflow-IR interpreter path) - `graphNativePostMerge` (**default-ON**; the graph is the sole owner of post-merge `optional-group` steps after a successful merge — the legacy merger-owned post-merge path was deleted. Post-merge failures are non-blocking. See [Workflow Steps → Execution Phases](./workflow-steps.md#execution-phases)) - `workflowInterpreterDualObserve` (retired/inert; stale persisted `true` values are forced OFF and must not reactivate hidden shadow observation) diff --git a/packages/core/src/__tests__/settings-defaults.test.ts b/packages/core/src/__tests__/settings-defaults.test.ts index e23d95719b..e95c29fb25 100644 --- a/packages/core/src/__tests__/settings-defaults.test.ts +++ b/packages/core/src/__tests__/settings-defaults.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CONSECUTIVE_TOOL_FAILURE_RETRY_THRESHOLD, DEFAULT_CONSECUTIVE_TOOL_FAILURE_RETRY_BACKOFF_MS, DEFAULT_MAX_CONSECUTIVE_TOOL_FAILURE_RETRIES, DEFAULT_MAX_AUTO_MERGE_RETRIES, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries } from "../tasks/in-review-stall.js"; -import { isExperimentalFeatureEnabled } from "../config/experimental-features.js"; +import { CHAT_FOCUS_FLAG, isExperimentalFeatureEnabled } from "../config/experimental-features.js"; import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalOnlySettingsKey, isProjectSettingsKey } from "../config/settings-schema.js"; import { __resetLegacyCwdMainWarningForTests, @@ -71,6 +71,13 @@ describe("settings defaults invariants", () => { expect(isExperimentalFeatureEnabled({ experimentalFeatures: { workflowInterpreterDualObserve: true } }, "workflowInterpreterDualObserve")).toBe(false); }); + it("keeps chat focus experimental and default off", () => { + expect(isExperimentalFeatureEnabled(undefined, CHAT_FOCUS_FLAG)).toBe(false); + expect(isExperimentalFeatureEnabled({ experimentalFeatures: {} }, CHAT_FOCUS_FLAG)).toBe(false); + expect(isExperimentalFeatureEnabled({ experimentalFeatures: { chatFocus: false } }, CHAT_FOCUS_FLAG)).toBe(false); + expect(isExperimentalFeatureEnabled({ experimentalFeatures: { chatFocus: true } }, CHAT_FOCUS_FLAG)).toBe(true); + }); + it("defaults maxAutoMergeRetries to the historical project-scoped cap", () => { expect(DEFAULT_PROJECT_SETTINGS.maxAutoMergeRetries).toBe(DEFAULT_MAX_AUTO_MERGE_RETRIES); expect("maxAutoMergeRetries" in DEFAULT_GLOBAL_SETTINGS).toBe(false); diff --git a/packages/core/src/config/experimental-features.ts b/packages/core/src/config/experimental-features.ts index c59da865f4..4ef7a588d3 100644 --- a/packages/core/src/config/experimental-features.ts +++ b/packages/core/src/config/experimental-features.ts @@ -44,6 +44,13 @@ WORKFLOW_INTERPRETER_DUAL_OBSERVE_FLAG read plumbing (named constant + */ export const GRAPH_NATIVE_POST_MERGE_FLAG = "graphNativePostMerge" as const; +/* +FNXC:ChatMemoryFocus 2026-08-24-04:21: +Per-conversation memory Focus is opt-in: the chip, /focus command, and recall scoping stay off +until operators enable this flag. Persisted focus topics remain inert while it is disabled. +*/ +export const CHAT_FOCUS_FLAG = "chatFocus" as const; + export function isExperimentalFeatureEnabled( settings: Pick | undefined, key: string, diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 72ae9526a8..d92f6f1615 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -2151,7 +2151,7 @@ export type { ResearchCancellationState, } from "./research/research-types.js"; -export { isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG } from "./config/experimental-features.js"; +export { isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG, CHAT_FOCUS_FLAG } from "./config/experimental-features.js"; export { POST_MERGE_VERIFICATION_GROUP_ID, postMergeOptionalGroupNode, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ad754b8f8a..b7218db370 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2389,7 +2389,7 @@ export type { ResearchCancellationState, } from "./research/research-types.js"; -export { isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG } from "./config/experimental-features.js"; +export { isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG, CHAT_FOCUS_FLAG } from "./config/experimental-features.js"; export { DEFAULT_MOBILE_NAV_PRIMARY_ITEMS, MAX_MOBILE_NAV_PRIMARY_ITEMS, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e4eadbc27d..370dd7895f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1552,6 +1552,13 @@ export { PROMPT_KEY_CATALOG } from "./tasks/prompt-overrides.js"; // Re-exported here so the dashboard's `@fusion/core` → types.ts alias resolves // client-side consumers (see packages/dashboard/vite.config.ts). export { getErrorMessage } from "./process/error-message.js"; + +/* +FNXC:ChatMemoryFocus 2026-08-24-04:21: +Dashboard client imports resolve @fusion/core to this browser-safe leaf, so expose the pure +experimental flag reader here. Its Settings dependency is type-only and introduces no browser runtime cycle. +*/ +export { isExperimentalFeatureEnabled, CHAT_FOCUS_FLAG } from "./config/experimental-features.js"; export { resolveExecutionSettingsModel, resolvePlanningSettingsModel, diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 11492965e9..793694e729 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -34,7 +34,7 @@ import { useChatUnread } from "../hooks/useChatUnread"; import { useComposerDictation } from "../hooks/useComposerDictation"; import { useViewportMode } from "./Header"; import { fetchSettings, fetchChatSession, updateGlobalSettings, type DiscoveredSkill } from "../api"; -import { type Agent, type ChatTag, type Settings } from "@fusion/core"; +import { isExperimentalFeatureEnabled, CHAT_FOCUS_FLAG, type Agent, type ChatTag, type Settings } from "@fusion/core"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { MicButton } from "./MicButton"; import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl"; @@ -70,7 +70,7 @@ import { formatModelTag, } from "./StandardChatSurface"; import { buildChatReportHandoff, type ChatReportHandoff } from "./chatReportHandoff"; -import { CHAT_COMMANDS, matchChatCommand, filterChatCommands, getSlashTriggerMatch, type ChatCommand } from "./chat-commands"; +import { matchChatCommand, filterChatCommands, getSlashTriggerMatch, selectChatCommands, type ChatCommand } from "./chat-commands"; import { useChatMessageLayout } from "../context/ChatMessageLayoutContext"; import { createChatInputAutosizeController, @@ -647,6 +647,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout }; }, [projectId]); const resolvedDefaultThinkingLevel = chatSettings?.defaultThinkingLevel ?? "off"; + const chatFocusEnabled = isExperimentalFeatureEnabled(chatSettings ?? undefined, CHAT_FOCUS_FLAG); + const selectedChatCommands = useMemo(() => selectChatCommands({ chatFocusEnabled }), [chatFocusEnabled]); const chatDefaultTarget = useMemo(() => { /* FNXC:ChatModels 2026-07-12-20:45: @@ -1128,8 +1130,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout // Chat surface never shows/dispatches them, so its skill-only behavior is unchanged. const filteredCommands = useMemo(() => { if (!chatCommandContext) return [] as ChatCommand[]; - return filterChatCommands(skillFilter, CHAT_COMMANDS); - }, [chatCommandContext, skillFilter]); + return filterChatCommands(skillFilter, selectedChatCommands); + }, [chatCommandContext, skillFilter, selectedChatCommands]); const skillMenuEntries = useMemo(() => { const commandEntries: SkillMenuEntry[] = filteredCommands.map((command) => ({ @@ -2051,7 +2053,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout if ((!trimmed && files.length === 0) || !activeSession) return; if (chatCommandContext) { - const commandMatch = matchChatCommand(trimmed, CHAT_COMMANDS); + const commandMatch = matchChatCommand(trimmed, selectedChatCommands); if (commandMatch) { // FNXC:ChatMemoryFocus (RUFU-068): only agent-gated commands (steer) are // refused without a running agent. /focus is a local session-setting command @@ -2180,6 +2182,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout chatCommandContext, isStreaming, releaseSentAttachments, + selectedChatCommands, t, ]); @@ -3325,19 +3328,19 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout {/* - FNXC:ChatMemoryFocus 2026-08-13: - RUFU-068: per-conversation memory focus chip for direct chat sessions. Persists - on chat_sessions.memory_focus so it survives reconnect; recall scoping is server-side - (within-project read filter), never a client post-query filter. Only the direct composer - shows it — rooms have no per-conversation focus. + FNXC:ChatMemoryFocus 2026-08-24-04:21: + Per-conversation memory focus is opt-in. Hide its direct-session chip until Settings + enables experimentalFeatures.chatFocus; persisted values remain inert while hidden. */} - setChatFocusOverride(focus)} - addToast={addToast} - /> + {chatFocusEnabled && ( + setChatFocusOverride(focus)} + addToast={addToast} + /> + )} {/* FNXC:Chat-ThinkingLevel 2026-07-16-00:34: FN-8030: direct sessions retain model/agent targeting here, while room composers reuse diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 9475690e3e..c842ed674a 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -519,6 +519,7 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record = { leftSidebarNav: "Left Sidebar Navigation", sandbox: "Sandbox (command isolation)", chatRooms: "Chat Rooms", + chatFocus: "Chat Focus (per-conversation memory recall)", agentOnboarding: "Planning-style Agent Onboarding", workflowInterpreterDualObserve: "Workflow Graph Engine — dual-observe parity (diagnostic)", }; diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.tsx b/packages/dashboard/app/components/TaskPlannerChatTab.tsx index 73d96a81d4..455d3da736 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.tsx +++ b/packages/dashboard/app/components/TaskPlannerChatTab.tsx @@ -1,6 +1,6 @@ -import type { ChatInFlightGenerationState, ChatMessage, ResolvedModelSelection, Task, TaskDetail } from "@fusion/core"; +import type { ChatInFlightGenerationState, ChatMessage, ResolvedModelSelection, Settings, Task, TaskDetail } from "@fusion/core"; import { isWipColumnRole } from "../utils/columnRoles"; -import { getErrorMessage } from "@fusion/core"; +import { getErrorMessage, isExperimentalFeatureEnabled, CHAT_FOCUS_FLAG } from "@fusion/core"; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Loader2, Maximize2, Minimize2 } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -9,7 +9,7 @@ import { useComposerDictation } from "../hooks/useComposerDictation"; import { getPersistedPendingChatMessages, setPersistedPendingChatMessages } from "../hooks/chatPendingMessageStorage"; import { MicButton } from "./MicButton"; import type { ChatMessageInfo, ToolCallInfo } from "../hooks/chatTypes"; -import { attachChatStream, cancelChatResponse, ensureTaskPlannerChatSession, fetchChatMessages, fetchChatSession, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse, updateChatSession, type ChatFailureInfo, type ChatStreamErrorMeta } from "../api"; +import { attachChatStream, cancelChatResponse, ensureTaskPlannerChatSession, fetchChatMessages, fetchChatSession, fetchSettings, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse, updateChatSession, type ChatFailureInfo, type ChatStreamErrorMeta } from "../api"; import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/parseQuestionToolCall"; import { ChatQuestionResponse } from "./ChatQuestionResponse"; import { PendingChatMessageQueue } from "./PendingChatMessageQueue"; @@ -18,7 +18,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown"; import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl"; import { useModelsCache } from "../hooks/useModelsCache"; import { StandardChatActionButton, StandardChatMessageItem, StandardStreamingMessage, formatModelTag } from "./StandardChatSurface"; -import { CHAT_COMMANDS, filterChatCommands, getSlashTriggerMatch, matchChatCommand, type ChatCommand } from "./chat-commands"; +import { filterChatCommands, getSlashTriggerMatch, matchChatCommand, selectChatCommands, type ChatCommand } from "./chat-commands"; import { useChatMessageLayout } from "../context/ChatMessageLayoutContext"; import { createChatInputAutosizeController, @@ -341,6 +341,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan persisted per-conversation focus without a full session refetch. */ const [sessionMemoryFocus, setSessionMemoryFocus] = useState(null); + const [chatSettings, setChatSettings] = useState(null); const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(""); const [pendingMessages, setPendingMessages] = useState([]); @@ -387,6 +388,23 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan taskChatModelRef.current = taskChatModel; }, [addToast, onTaskUpdated, taskChatModel]); + useEffect(() => { + let cancelled = false; + setChatSettings(null); + fetchSettings(projectId) + .then((settings) => { + if (!cancelled) setChatSettings(settings); + }) + .catch(() => { + if (!cancelled) setChatSettings(null); + }); + return () => { + cancelled = true; + }; + }, [projectId]); + + const chatFocusEnabled = isExperimentalFeatureEnabled(chatSettings ?? undefined, CHAT_FOCUS_FLAG); + const selectedChatCommands = useMemo(() => selectChatCommands({ chatFocusEnabled }), [chatFocusEnabled]); const [sessionModel, setSessionModel] = useState(taskChatModel); const hasLocalTargetOverrideRef = useRef(false); const { models, favoriteProviders, favoriteModels } = useModelsCache(); @@ -523,7 +541,10 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan planner edits could land against a task already being implemented. */ const agentRunning = isWipColumnRole(columnFlags, task.column); - const filteredCommands = useMemo(() => filterChatCommands(commandFilter, CHAT_COMMANDS), [commandFilter]); + const filteredCommands = useMemo( + () => filterChatCommands(commandFilter, selectedChatCommands), + [commandFilter, selectedChatCommands], + ); useEffect(() => { setHighlightedCommandIndex(0); @@ -1069,7 +1090,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan }, [messages, refreshMessagesForSession, refreshTaskAfterEdit, sessionId, startPlannerStream, t]); const dispatchSlashCommand = useCallback(async (command: ChatCommand, remainder: string) => { - if (!agentRunning) { + if (command.requiresAgent && !agentRunning) { // Do not silently fall back to a normal chat message: /steer with no // running agent is a no-op with feedback, not a plain send. addToastRef.current(t("taskDetail.plannerChat.commandNoRunningAgent", "No running agent to steer"), "warning"); @@ -1128,13 +1149,13 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan const sendMessage = useCallback(() => { const trimmed = draft.trim(); - const commandMatch = matchChatCommand(trimmed, CHAT_COMMANDS); + const commandMatch = matchChatCommand(trimmed, selectedChatCommands); if (commandMatch) { setShowCommandMenu(false); return dispatchSlashCommand(commandMatch.command, commandMatch.remainder); } return sendMessageContent(draft); - }, [draft, dispatchSlashCommand, sendMessageContent]); + }, [draft, dispatchSlashCommand, selectedChatCommands, sendMessageContent]); const handleDraftChange = useCallback((event: React.ChangeEvent) => { const nextValue = event.target.value; @@ -1622,15 +1643,22 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan )}
)} -
- setSessionMemoryFocus(focus)} - addToast={(message, type) => addToastRef.current(message, type)} - /> -
+ {/* + FNXC:ChatMemoryFocus 2026-08-24-04:21: + Suppress the focus chip and its padded wrapper together until experimentalFeatures.chatFocus + is enabled, so default-off planner chat leaves no empty composer shell. + */} + {chatFocusEnabled && ( +
+ setSessionMemoryFocus(focus)} + addToast={(message, type) => addToastRef.current(message, type)} + /> +
+ )}
({ + 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("../ChatFocusSelector", () => ({ + ChatFocusSelector: () =>