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/.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/.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/.changeset/preserve-dirty-worktrees-minimal.md b/.changeset/preserve-dirty-worktrees-minimal.md new file mode 100644 index 0000000000..550682dcf3 --- /dev/null +++ b/.changeset/preserve-dirty-worktrees-minimal.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Preserve dirty or unverifiable worktrees during automatic cleanup. +category: fix +dev: Automatic cleanup now fails closed for unverified content and revalidates cleanliness without force at removal time. 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 8cf3c67aeb..0d45ad454b 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -212,7 +212,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. | @@ -1705,6 +1705,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 42b61c91e0..7786cbbf4a 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, isGlobalSettingsKey, isProjectSettingsKey } from "../config/settings-schema.js"; import { __resetLegacyCwdMainWarningForTests, @@ -78,6 +78,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 32989f954a..f35d4811e9 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -2187,7 +2187,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 bfd314503e..1c4d97da57 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2426,7 +2426,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 ab65a4b1dd..bea7769a2a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1564,6 +1564,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/ChatFocusSelector.css b/packages/dashboard/app/components/ChatFocusSelector.css index d384c2e461..e36e986357 100644 --- a/packages/dashboard/app/components/ChatFocusSelector.css +++ b/packages/dashboard/app/components/ChatFocusSelector.css @@ -1,29 +1,22 @@ /* -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. + +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 { - position: relative; + position: static; flex: none; } @@ -57,8 +50,14 @@ in styles.css as documentation-only (custom properties cannot appear in 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; @@ -68,11 +67,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 +103,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 +111,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/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/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 7f3ee9d23d..dbfce65329 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -32,7 +32,7 @@ import { useChatUnread } from "../hooks/useChatUnread"; import { useComposerDictation } from "../hooks/useComposerDictation"; import { useViewportMode } from "./Header"; import { fetchSettings, fetchChatSession, 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 { MicButton } from "./MicButton"; import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl"; import { ChatThreadTitleSwitcher } from "./ChatThreadTitleSwitcher"; @@ -66,7 +66,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, @@ -408,6 +408,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: @@ -815,8 +817,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) => ({ @@ -1657,7 +1659,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 @@ -1786,6 +1788,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout chatCommandContext, isStreaming, releaseSentAttachments, + selectedChatCommands, t, ]); @@ -2902,19 +2905,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. The direct composer owns - this per-conversation control. + 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-08-24-03:34: Direct sessions retain model/agent targeting here. CLI-backed sessions broker to a live PTY and never receive 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.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/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: () =>