diff --git a/.changeset/settings-default-surfaces.md b/.changeset/settings-default-surfaces.md new file mode 100644 index 0000000000..83bc82d0e8 --- /dev/null +++ b/.changeset/settings-default-surfaces.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Preserve unrelated global settings when saving Settings sections, and graduate Chat Rooms, Goals, Memory, Insights, Skills, and Todo to default-on dashboard surfaces. diff --git a/.changeset/task-card-files-changed-inline.md b/.changeset/task-card-files-changed-inline.md new file mode 100644 index 0000000000..0cb48179d7 --- /dev/null +++ b/.changeset/task-card-files-changed-inline.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Open task-card files changed actions in the inline task detail Changes tab instead of the task modal. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 19614ce113..c2fceb2ddd 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -556,8 +556,12 @@ function AppInner() { /* FNXC:Navigation 2026-06-22-00:00: Snapshot of the task whose detail is shown in the main panel (Board card click → full-panel detail). Kept as a snapshot so the view survives a tasks revalidation; renderMainContent prefers the live row from `tasks` by id and falls back to this snapshot. + + FNXC:TaskDetail 2026-06-23-00:41: + Board task-card secondary actions can deep-link into the inline main-panel task detail. Files-changed must land on the embedded Changes tab instead of reopening the task in the modal path. */ const [mainPanelDetailTask, setMainPanelDetailTask] = useState(null); + const [mainPanelDetailInitialTab, setMainPanelDetailInitialTab] = useState("chat"); const boardScrollSnapshotRef = useRef(null); const pendingBoardScrollRestoreRef = useRef(false); @@ -1113,7 +1117,8 @@ function AppInner() { previousCapacityRiskTodoThresholdRef.current = capacityRiskTodoThreshold; }, [settingsLoaded, capacityRiskBannerEnabled, capacityRiskTodoThreshold, currentProject?.id]); - const skillsEnabled = experimentalFeatures.skillsView === true; + /* FNXC:DefaultNavigation 2026-06-23-01:26: Skills graduated from Experimental and should remain visible on upgrades even when stale `experimentalFeatures.skillsView=false` is present. */ + const skillsEnabled = true; const nodesEnabled = experimentalFeatures.nodesView === true; const researchEnabled = experimentalFeatures.researchView === true; const evalsEnabled = experimentalFeatures.evalsView === true; @@ -1276,15 +1281,6 @@ function AppInner() { addToast, }); - const handleOpenDetailWithTab = useCallback((task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => { - if (initialTab === "changes") { - modalManager.openDetailWithChangesTab(task); - } else { - modalManager.openDetailTask(task, initialTab); - } - pushNav({ type: "modal", close: modalManager.closeDetailTask }); - }, [modalManager, pushNav]); - const handleOpenTaskLogs = useCallback(async (taskId: string) => { try { const task = await fetchTaskDetail(taskId, currentProject?.id); @@ -1332,9 +1328,10 @@ function AppInner() { FNXC:Navigation 2026-06-22-00:00: Board card clicks open task detail as a full main-content view that replaces the board (design: "Full main panel (replaces board)"), instead of the TaskDetailModal overlay. We store a snapshot of the clicked task and navigate to the registered `task-detail` view; renderMainContent renders TaskDetailContent embedded with a Back-to-board button. Only the Board uses this handler — list-view split-detail, right-dock cards, and other openDetail callers keep the modal behavior. */ - const openTaskDetailInMainPanel = useCallback((task: Task | TaskDetail) => { + const openTaskDetailInMainPanel = useCallback((task: Task | TaskDetail, initialTab: DetailTaskTab = "chat") => { captureCurrentBoardScrollSnapshot(); setMainPanelDetailTask(task); + setMainPanelDetailInitialTab(initialTab); handleTaskViewChange("task-detail"); }, [captureCurrentBoardScrollSnapshot, handleTaskViewChange]); @@ -1342,9 +1339,19 @@ function AppInner() { const closeTaskDetailMainPanel = useCallback(() => { pendingBoardScrollRestoreRef.current = true; setMainPanelDetailTask(null); + setMainPanelDetailInitialTab("chat"); handleTaskViewChange("board"); }, [handleTaskViewChange]); + const handleOpenDetailWithTab = useCallback((task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => { + if (initialTab === "changes") { + openTaskDetailInMainPanel(task, "changes"); + return; + } + modalManager.openDetailTask(task, initialTab); + pushNav({ type: "modal", close: modalManager.closeDetailTask }); + }, [modalManager, openTaskDetailInMainPanel, pushNav]); + /* FNXC:Settings 2026-06-22-00:00: Settings is now a main-content destination. The header/sidebar entry points navigate to the embedded `settings` view (carrying the requested deep-link section via setSettingsSection) instead of opening the modal overlay. handleTaskViewChange owns the back-navigation history entry, so no modal nav entry is pushed here. @@ -2117,6 +2124,7 @@ function AppInner() { projectId={currentProject?.id} tasks={tasks} embedded + initialTab={mainPanelDetailInitialTab} /* FNXC:TaskDetail 2026-06-22-18:40: Board-card detail (full main panel) renders its "Back to board" affordance inside TaskDetailContent's gray header (far right, across from the task id) instead of a separate back-row above the content. The prop only renders the header back button when both embedded and onBackToBoard are present, so ListView split-pane and modal usages stay unaffected. @@ -2124,7 +2132,10 @@ function AppInner() { onBackToBoard={closeTaskDetailMainPanel} /* FNXC:FloatingWindow 2026-06-22-21:10: Popping out from the board's full-panel detail also returns the main panel to the board, so the board (not the emptied detail) sits behind the floating window. */ onPopOut={(task) => { popOutTaskDetail(task); closeTaskDetailMainPanel(); }} - onOpenDetail={(value) => setMainPanelDetailTask(value)} + onOpenDetail={(value) => { + setMainPanelDetailTask(value); + setMainPanelDetailInitialTab("chat"); + }} onMoveTask={moveTask} onDeleteTask={deleteTask} onMergeTask={mergeTask} diff --git a/packages/dashboard/app/__tests__/settings-save-split.test.ts b/packages/dashboard/app/__tests__/settings-save-split.test.ts index 97a4375470..8816f29181 100644 --- a/packages/dashboard/app/__tests__/settings-save-split.test.ts +++ b/packages/dashboard/app/__tests__/settings-save-split.test.ts @@ -5,6 +5,7 @@ * - one global + one project edit in a single session produce the expected * `updateGlobalSettings` / `updateSettings` patches with strict scope routing; * - clearing a project override emits null-as-delete; + * - untouched global values are NOT written (changed-only gate); * - untouched inherited project values are NOT written (changed-only gate); * - explicit clears of global keys emit null, plain undefined is dropped. * @@ -62,6 +63,113 @@ describe("splitSettingsSave", () => { expect(projectPatch).toEqual({ maxConcurrent: 5 }); }); + it("does not write global values that match the initial global-scoped value", () => { + const initialScopedValues = { + global: { + ntfyEnabled: true, + ntfyTopic: "alerts", + ntfyEvents: ["failed", "merged"], + notificationProviders: [{ id: "ntfy-main", type: "ntfy", enabled: true }], + experimentalFeatures: { insights: true }, + }, + project: {}, + } as never; + + const payload: Record = { + ntfyEnabled: true, + ntfyTopic: "alerts", + ntfyEvents: ["failed", "merged"], + notificationProviders: [{ id: "ntfy-main", type: "ntfy", enabled: true }], + experimentalFeatures: { insights: true }, + }; + + const { globalPatch } = splitSettingsSave({ + payload, + initialValues: { + ntfyEnabled: true, + ntfyTopic: "alerts", + ntfyEvents: ["failed", "merged"], + notificationProviders: [{ id: "ntfy-main", type: "ntfy", enabled: true }], + experimentalFeatures: { insights: true }, + } as never, + initialScopedValues, + activeSection: "notifications", + }); + + expect(globalPatch).toEqual({}); + }); + + it("writes only the changed global value and does not carry unrelated defaults", () => { + const initialValues = { + colorTheme: "ocean", + ntfyEnabled: true, + ntfyTopic: "alerts", + modelOnboardingComplete: true, + experimentalFeatures: { insights: true }, + } as never; + const initialScopedValues = { + global: { + colorTheme: "ocean", + ntfyEnabled: true, + ntfyTopic: "alerts", + modelOnboardingComplete: true, + experimentalFeatures: { insights: true }, + }, + project: {}, + } as never; + + const payload: Record = { + colorTheme: "shadcn-gray-blue", + ntfyEnabled: false, + ntfyTopic: undefined, + modelOnboardingComplete: undefined, + experimentalFeatures: { insights: true }, + }; + + const { globalPatch } = splitSettingsSave({ + payload, + initialValues, + initialScopedValues, + activeSection: "appearance", + }); + + expect(globalPatch).toEqual({ colorTheme: "shadcn-gray-blue" }); + }); + + it("does not carry notification defaults when saving experimental features", () => { + const initialValues = { + experimentalFeatures: { researchView: true }, + ntfyEnabled: true, + ntfyTopic: "alerts", + modelOnboardingComplete: true, + } as never; + const initialScopedValues = { + global: { + experimentalFeatures: { researchView: true }, + ntfyEnabled: true, + ntfyTopic: "alerts", + modelOnboardingComplete: true, + }, + project: {}, + } as never; + + const payload: Record = { + experimentalFeatures: { researchView: true, evalsView: true }, + ntfyEnabled: false, + ntfyTopic: undefined, + modelOnboardingComplete: undefined, + }; + + const { globalPatch } = splitSettingsSave({ + payload, + initialValues, + initialScopedValues, + activeSection: "experimental", + }); + + expect(globalPatch).toEqual({ experimentalFeatures: { researchView: true, evalsView: true } }); + }); + it("does not write project values that match the initial project-scoped value (changed-only gate)", () => { // The gate compares the payload value against the initial *project-scoped* // value: a value equal to its initial override is not re-written. This is @@ -190,9 +298,7 @@ describe("splitSettingsSave", () => { activeSection: "notifications", }); - // undefined survives the object but is dropped by JSON.stringify on the wire; - // the patch must not coerce it to null when there was nothing to clear. - expect(globalPatch.ntfyTopic).toBeUndefined(); + expect(globalPatch).toEqual({}); }); it("routes githubTrackingDefaultRepo to global only on the global-general section", () => { diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 3ffe0c6621..f044e0b7e5 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -986,7 +986,7 @@ const ChatMessageItem = memo(function ChatMessageItem({ ); }); -export function ChatView({ projectId, addToast, experimentalFeatures, floating = false, onPopOut, onMaximize, onMinimize, onClose }: ChatViewProps) { +export function ChatView({ projectId, addToast, floating = false, onPopOut, onMaximize, onMinimize, onClose }: ChatViewProps) { const { t } = useTranslation("app"); useEffect(() => { recordResumeEvent({ @@ -1035,7 +1035,8 @@ export function ChatView({ projectId, addToast, experimentalFeatures, floating = } = useChat(projectId, addToast); const [showNewDialog, setShowNewDialog] = useState(false); - const chatRoomsEnabled = experimentalFeatures?.chatRooms === true; + /* FNXC:ChatRooms 2026-06-23-01:28: Chat Rooms graduated from Experimental; stale false flags should not hide rooms in the main view, popout modal, or quick-chat surfaces. */ + const chatRoomsEnabled = true; const [chatScope, setChatScope] = useState<"direct" | "rooms">(() => { try { const persistedScope = localStorage.getItem(CHAT_SCOPE_STORAGE_KEY); diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 77098a0fd6..817d186eb2 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -297,10 +297,19 @@ The Roadmaps dashboard view and experiment were removed from the product surface FNXC:SettingsExperimental 2026-06-22-18:00: Right Dock Panel is no longer experimental: keep honoring the dock as always-on in App, but hide any stale persisted `rightDock` setting from the Experimental list. + +FNXC:SettingsExperimental 2026-06-23-01:31: +Chat Rooms, Goals, Memory, Insights, Skills, and Todo graduated from Experimental. Hide stale persisted flags so users cannot accidentally disable now-default dashboard surfaces during upgrades. */ const HIDDEN_EXPERIMENTAL_FEATURE_KEYS = new Set([ + "chatRooms", + "goalsView", + "insights", + "memoryView", "roadmap", "rightDock", + "skillsView", + "todoView", "workflowColumns", "workflowGraphExecutor", "workflowInterpreterDualObserve", diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index faf79c5a54..b77dac3431 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -147,7 +147,7 @@ function getModelForRole( return getExplicitModelForRole(task, role) ?? getRuntimeModelForRole(entries, role) ?? getEffectiveModelForRole(effectiveModels, role); } -function TaskChatAgentIcon({ label, modelInfo, role }: { label: string; modelInfo: TaskChatModelInfo | null; role: AgentLogRole }) { +function TaskChatAgentIcon({ label, modelInfo }: { label: string; modelInfo: TaskChatModelInfo | null }) { if (modelInfo?.provider) { const title = modelInfo.modelId ? `${label}: ${modelInfo.provider}/${modelInfo.modelId}` : `${label}: ${modelInfo.provider}`; return ( @@ -889,7 +889,7 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on return (
- +
{item.label}
diff --git a/packages/dashboard/app/components/__tests__/ChatView.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.test.tsx index a28b463923..85f40464a8 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.test.tsx @@ -3566,12 +3566,12 @@ describe("Direct/Rooms scope toggle", () => { localStorage.clear(); }); - it("hides rooms UI when chatRooms experimental flag is off", async () => { + it("shows rooms UI when chatRooms experimental flag is missing", async () => { setupMockChat({ sessions: [], filteredSessions: [] }); await renderWithAct(); - expect(screen.queryByTestId("chat-sidebar-scope-rooms")).not.toBeInTheDocument(); + expect(screen.getByTestId("chat-sidebar-scope-rooms")).toBeInTheDocument(); expect(screen.queryByTestId("chat-sidebar-rooms")).not.toBeInTheDocument(); }); @@ -3668,14 +3668,14 @@ describe("Direct/Rooms scope toggle", () => { }); }); - it("forces direct scope when localStorage persisted rooms but chatRooms is off", async () => { + it("restores persisted rooms scope when chatRooms experimental flag is missing", async () => { setupMockChat({ sessions: [], filteredSessions: [] }); localStorage.setItem("fusion:chat-scope", "rooms"); await renderWithAct(); - expect(screen.queryByTestId("chat-sidebar-scope-rooms")).not.toBeInTheDocument(); - expect(screen.getByTestId("chat-search-input")).toBeInTheDocument(); + expect(screen.getByTestId("chat-sidebar-scope-rooms")).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("chat-sidebar-rooms-empty")).toBeInTheDocument(); }); it("persists scope in localStorage and restores Rooms on next mount", async () => { diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index 7d71c5fdc9..6b9602f3c2 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -3842,8 +3842,8 @@ describe("SettingsModal", () => { renderModal(); await openExperimentalFeaturesSection(); - // Known features are always shown even with no custom features configured. - expect(screen.getByText("Insights")).toBeInTheDocument(); + // Known features that remain experimental are shown even with no custom features configured. + expect(screen.queryByText("Insights")).not.toBeInTheDocument(); // FNXC:SettingsExperimental 2026-06-22-18:50: Roadmaps was removed from Experimental and must not render as a known or stale toggle. expect(screen.queryByText("Roadmaps")).not.toBeInTheDocument(); @@ -3851,7 +3851,6 @@ describe("SettingsModal", () => { "Research View", "Evals View", "Subtask Breakdown", - "Chat Rooms", "Sandbox (command isolation)", "Planning-style Agent Onboarding", ]) { @@ -4047,12 +4046,13 @@ describe("SettingsModal", () => { it("does not emit legacy alias null deletes when canonical key is absent", async () => { mockFetchSettings.mockResolvedValue({ ...defaultSettings, - experimentalFeatures: { insights: true }, + experimentalFeatures: { "my-feature": false }, }); renderModal(); await openExperimentalFeaturesSection(); + await userEvent.click(screen.getByLabelText("my-feature")); await userEvent.click(screen.getByText("Save")); @@ -4061,7 +4061,7 @@ describe("SettingsModal", () => { }); const payload = mockUpdateGlobalSettings.mock.calls[0][0]; - expect(payload.experimentalFeatures).toEqual({ insights: true }); + expect(payload.experimentalFeatures).toEqual({ "my-feature": true }); expect(payload.experimentalFeatures.devServer).toBeUndefined(); }); @@ -4073,6 +4073,7 @@ describe("SettingsModal", () => { workflowGraphExecutor: false, workflowInterpreterDualObserve: true, insights: true, + "my-feature": false, }, }); @@ -4083,6 +4084,9 @@ describe("SettingsModal", () => { expect(screen.queryByText("workflowColumns")).not.toBeInTheDocument(); expect(screen.queryByText("workflowGraphExecutor")).not.toBeInTheDocument(); expect(screen.queryByText(/dual-observe parity/i)).not.toBeInTheDocument(); + expect(screen.queryByText("Insights")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText("my-feature")); await userEvent.click(screen.getByText("Save")); @@ -4099,6 +4103,7 @@ describe("SettingsModal", () => { workflowGraphExecutor: false, workflowInterpreterDualObserve: true, insights: true, + "my-feature": true, }); }); @@ -4245,8 +4250,9 @@ describe("SettingsModal", () => { await openExperimentalFeaturesSection(); - // Known features should always be shown regardless of settings - expect(screen.getByText("Insights")).toBeInTheDocument(); + // Known features that remain experimental should always be shown regardless of settings. + expect(screen.getByText("Dev Server")).toBeInTheDocument(); + expect(screen.queryByText("Insights")).not.toBeInTheDocument(); expect(screen.queryByText("Roadmaps")).not.toBeInTheDocument(); }); diff --git a/packages/dashboard/app/components/__tests__/navigation-history.test.tsx b/packages/dashboard/app/components/__tests__/navigation-history.test.tsx index 885bfde201..c76f7e8f4a 100644 --- a/packages/dashboard/app/components/__tests__/navigation-history.test.tsx +++ b/packages/dashboard/app/components/__tests__/navigation-history.test.tsx @@ -146,12 +146,27 @@ vi.mock("../../components/model-onboarding-state", () => ({ })); vi.mock("../../components/Board", () => ({ - Board: ({ tasks, onOpenDetail }: { tasks: Task[]; onOpenDetail: (task: Task) => void }) => ( + Board: ({ + tasks, + onOpenDetail, + onOpenDetailWithTab, + }: { + tasks: Task[]; + onOpenDetail: (task: Task) => void; + onOpenDetailWithTab?: (task: Task, initialTab: "changes" | "retries" | "workflow") => void; + }) => (
{tasks.map((task) => ( - +
+ + {task.modifiedFiles && task.modifiedFiles.length > 0 ? ( + + ) : null} +
))}
), @@ -171,9 +186,11 @@ vi.mock("../../components/TaskDetailModal", () => ({ TaskDetailContent: ({ task, onBackToBoard, + initialTab, }: { task: { id: string; title?: string }; onBackToBoard?: () => void; + initialTab?: string; }) => (
{onBackToBoard && ( @@ -181,6 +198,7 @@ vi.mock("../../components/TaskDetailModal", () => ({ Back to board )} +

tab:{initialTab ?? "chat"}

{task.title ?? task.id}

), @@ -656,6 +674,44 @@ describe("Navigation history integration", () => { }); }); + it("opens board files-changed actions inline on the changes tab instead of in a modal", async () => { + const task = { + ...makeTask("FN-1", "Inline Changes Detail"), + modifiedFiles: ["packages/dashboard/app/App.tsx"], + }; + mockUseTasks.mockImplementation(() => ({ + tasks: [task], + createTask: mockCreateTask, + moveTask: vi.fn(), + deleteTask: vi.fn(), + mergeTask: vi.fn(), + retryTask: vi.fn(), + updateTask: vi.fn(), + duplicateTask: vi.fn(), + archiveTask: vi.fn(), + unarchiveTask: vi.fn(), + archiveAllDone: vi.fn(), + refreshTasks: vi.fn(), + })); + + await renderMobileAppAndWait(); + + // FNXC:TaskDetail 2026-06-23-00:41: Board files-changed chips must deep-link to the embedded main-panel Changes tab. They should not open the TaskDetailModal, otherwise the board loses the inline changes-page flow. + fireEvent.click(screen.getByTestId("open-task-changes-FN-1")); + + await waitFor(() => { + expect(screen.getByTestId("task-detail-main-panel-content")).toBeTruthy(); + expect(screen.getByText("tab:changes")).toBeTruthy(); + }); + expect(screen.queryByTestId("task-detail-modal")).toBeNull(); + + dispatchPopState({ navIndex: 0 }); + await waitFor(() => { + expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull(); + expect(screen.getByTestId("board-view")).toBeTruthy(); + }); + }); + // 5. Verify useNavigationHistory is called with enabled=true on mobile it("calls useNavigationHistory with enabled=true on mobile", async () => { mockUseViewportMode.mockReturnValue("mobile"); diff --git a/packages/dashboard/app/components/settings/save-split.ts b/packages/dashboard/app/components/settings/save-split.ts index ceb579f1e8..94b98f3d04 100644 --- a/packages/dashboard/app/components/settings/save-split.ts +++ b/packages/dashboard/app/components/settings/save-split.ts @@ -8,11 +8,14 @@ * 1. Global keys are routed via {@link isGlobalSettingsKey} to the global * patch; project keys via {@link isProjectSettingsKey} to the project * patch. (A key can be neither — server-only/UI-only fields are dropped.) - * 2. null-as-delete: an explicit clear (current value `undefined`, but the + * 2. Global and project writes are changed-only. This prevents any Settings + * save from re-sending default global values that can overwrite unrelated + * user preferences such as notifications or onboarding state. + * 3. null-as-delete: an explicit clear (current value `undefined`, but the * initial value was defined) is written as `null` so it survives * `JSON.stringify` and tells the server to delete the key. Plain * `undefined` is dropped. - * 3. changed-only project writes: an inherited/effective project value that + * 4. changed-only project writes: an inherited/effective project value that * the user never touched is NOT serialized as an explicit override — * doing so would silently break inheritance for every project setting on * every save. Only keys whose value differs from the initial project-scoped @@ -42,6 +45,119 @@ export const MODEL_LANE_KEYS = [ const MODEL_LANE_KEY_SET = new Set(MODEL_LANE_KEYS); +const GLOBAL_SECTION_KEYS: Record> = { + appearance: new Set([ + "themeMode", + "colorTheme", + "dashboardFontScalePct", + "shadcnCustomColors", + ]), + notifications: new Set([ + "ntfyEnabled", + "ntfyTopic", + "ntfyBaseUrl", + "ntfyAccessToken", + "ntfyEvents", + "ntfyDashboardHost", + "failureNotificationDelayMs", + "failureNotificationMode", + "webhookEnabled", + "webhookUrl", + "webhookFormat", + "webhookEvents", + "notificationProviders", + ]), + experimental: new Set(["experimentalFeatures"]), + "global-general": new Set([ + "githubTrackingDefaultRepo", + "language", + "persistAgentToolOutput", + "persistAgentThinkingLogPermanent", + "persistAgentThinkingLogEphemeral", + "fnBinaryCheckEnabled", + "updateCheckEnabled", + "updateCheckFrequency", + "autoReloadOnVersionChange", + ]), + "global-models": new Set([ + "defaultProvider", + "defaultModelId", + "fallbackProvider", + "fallbackModelId", + "defaultThinkingLevel", + "modelRouterEnabled", + "modelRouterCheapProvider", + "modelRouterCheapModelId", + "opencodeGoModelSync", + "openrouterAppAttribution", + "openrouterModelFilters", + "openrouterModelSync", + "openrouterProviderPreferences", + "executionGlobalProvider", + "executionGlobalModelId", + "planningGlobalProvider", + "planningGlobalModelId", + "validatorGlobalProvider", + "validatorGlobalModelId", + "titleSummarizerGlobalProvider", + "titleSummarizerGlobalModelId", + ]), + "project-models": new Set([ + "defaultProvider", + "defaultModelId", + "fallbackProvider", + "fallbackModelId", + "defaultThinkingLevel", + "modelRouterEnabled", + "modelRouterCheapProvider", + "modelRouterCheapModelId", + "opencodeGoModelSync", + "openrouterAppAttribution", + "openrouterModelFilters", + "openrouterModelSync", + "openrouterProviderPreferences", + "executionGlobalProvider", + "executionGlobalModelId", + "planningGlobalProvider", + "planningGlobalModelId", + "validatorGlobalProvider", + "validatorGlobalModelId", + "titleSummarizerGlobalProvider", + "titleSummarizerGlobalModelId", + ]), + "node-sync": new Set([ + "settingsSyncEnabled", + "settingsSyncAuth", + "settingsSyncInterval", + "settingsSyncConflictResolution", + ]), + "research-global": new Set([ + "researchGlobalDefaults", + "researchGlobalEnabled", + "researchGlobalMaxConcurrentRuns", + "researchGlobalDefaultTimeout", + "researchGlobalMaxSourcesPerRun", + "researchGlobalMaxSynthesisRounds", + "researchGlobalWebSearchProvider", + "researchGlobalSearxngUrl", + "researchGlobalBraveApiKey", + "researchGlobalGoogleSearchApiKey", + "researchGlobalGoogleSearchCx", + "researchGlobalTavilyApiKey", + "researchGlobalGitHubEnabled", + "researchGlobalLocalDocsEnabled", + "researchGlobalMaxSearchResults", + "researchGlobalFetchTimeoutMs", + "researchGlobalUserAgent", + ]), + remote: new Set(["remoteAccess"]), +}; + +function isGlobalKeyAllowedForSection(key: string, activeSection: string): boolean { + const sectionKeys = GLOBAL_SECTION_KEYS[activeSection]; + return !sectionKeys || sectionKeys.has(key); +} + export interface SaveSplitInput { /** The fully-normalized form payload (after trimming/normalization). */ payload: Record; @@ -58,6 +174,31 @@ export interface SaveSplitResult { projectPatch: Partial; } +function hasOwn(obj: object | null | undefined, key: string): boolean { + return !!obj && Object.prototype.hasOwnProperty.call(obj, key); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function settingsValueEquals(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right)) return false; + if (left.length !== right.length) return false; + return left.every((item, index) => settingsValueEquals(item, right[index])); + } + if (isPlainObject(left) || isPlainObject(right)) { + if (!isPlainObject(left) || !isPlainObject(right)) return false; + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) return false; + return leftKeys.every((key) => hasOwn(right, key) && settingsValueEquals(left[key], right[key])); + } + return false; +} + /** * Split a normalized settings form payload into global and project patches, * preserving null-as-delete and changed-only-project-write semantics. @@ -85,11 +226,37 @@ export function splitSettingsSave({ continue; } if (isGlobalSettingsKey(key)) { - // null-as-delete: explicit clear is sent as null, plain undefined dropped. - const initialValue = initialValues?.[key as keyof GlobalSettings]; - if (value === undefined && initialValue !== undefined) { + /* + FNXC:SettingsPersistence 2026-06-23-00:55: + Global settings saves must be changed-only, just like project settings. The Settings form carries full default-shaped global values, so emitting unchanged globals can overwrite unrelated user preferences (notifications, onboarding state, theme) when a user saves another section or when experimental-feature normalization allocates a fresh but equivalent object. + + FNXC:SettingsPersistence 2026-06-23-01:18: + Global Settings saves are also gated by the active settings section. The form can contain stale/default values from sections the user did not edit, so changed-only comparison alone cannot distinguish an intentional Appearance edit from a default-filled Notifications or onboarding field. + */ + if (!isGlobalKeyAllowedForSection(key, activeSection)) { + continue; + } + + if (value === undefined && key === "ntfyAccessToken" && activeSection === "notifications") { (globalPatch as Record)[key] = null; - } else { + continue; + } + + const hasScopedInitial = hasOwn(initialScopedValues?.global, key); + const hasMergedInitial = hasOwn(initialValues, key); + const initialValue = hasScopedInitial + ? initialScopedValues?.global?.[key as keyof GlobalSettings] + : initialValues?.[key as keyof GlobalSettings]; + const hasInitialValue = hasScopedInitial || hasMergedInitial; + + if (settingsValueEquals(value, initialValue)) { + continue; + } + + // null-as-delete: explicit clear is sent as null, plain undefined dropped. + if (value === undefined && hasInitialValue && initialValue !== undefined) { + (globalPatch as Record)[key] = null; + } else if (value !== undefined) { (globalPatch as Record)[key] = value; } } @@ -105,7 +272,7 @@ export function splitSettingsSave({ const initialProjectValue = initialScopedValues?.project?.[key as keyof Settings]; if (MODEL_LANE_KEY_SET.has(key)) { - if (value !== initialProjectValue) { + if (!settingsValueEquals(value, initialProjectValue)) { if ( (value === undefined || value === null) && initialProjectValue !== undefined && @@ -118,7 +285,7 @@ export function splitSettingsSave({ } } else { // Changed-only gate + null-as-delete for non-model project settings. - if (value !== initialProjectValue) { + if (!settingsValueEquals(value, initialProjectValue)) { if (value === undefined && initialProjectValue !== undefined && initialProjectValue !== null) { (projectPatch as Record)[key] = null; } else if (value !== undefined) { diff --git a/packages/dashboard/app/hooks/useAppSettings.ts b/packages/dashboard/app/hooks/useAppSettings.ts index b88e06f42e..2111dc7a2c 100644 --- a/packages/dashboard/app/hooks/useAppSettings.ts +++ b/packages/dashboard/app/hooks/useAppSettings.ts @@ -62,11 +62,11 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { const [prAuthAvailable, setPrAuthAvailable] = useState(false); const [settingsLoaded, setSettingsLoaded] = useState(false); const [experimentalFeatures, setExperimentalFeatures] = useState>({}); - const [insightsEnabled, setInsightsEnabled] = useState(false); - const [memoryEnabled, setMemoryEnabled] = useState(false); + const [insightsEnabled, setInsightsEnabled] = useState(true); + const [memoryEnabled, setMemoryEnabled] = useState(true); const [devServerEnabled, setDevServerEnabled] = useState(false); - const [todosEnabled, setTodosEnabled] = useState(false); - const [goalsEnabled, setGoalsEnabled] = useState(false); + const [todosEnabled, setTodosEnabled] = useState(true); + const [goalsEnabled, setGoalsEnabled] = useState(true); const [autoReloadOnVersionChange, setAutoReloadOnVersionChangeState] = useState(true); const autoMergeRef = useRef(autoMerge); @@ -112,11 +112,15 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { setCapacityRiskTodoThreshold(settings.capacityRiskTodoThreshold ?? 20); setExperimentalFeatures(settings.experimentalFeatures ?? {}); const features = settings.experimentalFeatures ?? {}; - setInsightsEnabled(features.insights === true); - setMemoryEnabled(features.memoryView === true); + /* + FNXC:DefaultNavigation 2026-06-23-01:24: + Insights, Memory, Todo, and Goals graduated from experimental navigation. Keep them enabled regardless of missing or stale false experimental flags so upgrades keep the sidebar/header surfaces visible. + */ + setInsightsEnabled(true); + setMemoryEnabled(true); setDevServerEnabled(features.devServerView === true || features.devServer === true); - setTodosEnabled(features.todoView === true); - setGoalsEnabled(features.goalsView === true); + setTodosEnabled(true); + setGoalsEnabled(true); // Sync the module-level auto-reload guard with the persisted setting const autoReload = settings.autoReloadOnVersionChange !== false; setAutoReloadOnVersionChangeState(autoReload); @@ -129,11 +133,11 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { useEffect(() => { setSettingsLoaded(false); setExperimentalFeatures({}); - setInsightsEnabled(false); - setMemoryEnabled(false); + setInsightsEnabled(true); + setMemoryEnabled(true); setDevServerEnabled(false); - setTodosEnabled(false); - setGoalsEnabled(false); + setTodosEnabled(true); + setGoalsEnabled(true); void refresh(); }, [refresh]);