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: () =>