fix(FN-6951): preserve settings saves and inline file changes

This commit is contained in:
gsxdsm
2026-06-23 01:05:13 -07:00
parent 90cba95bdd
commit 192a2f2e94
12 changed files with 425 additions and 55 deletions

View File

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

View File

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

View File

@@ -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<Task | TaskDetail | null>(null);
const [mainPanelDetailInitialTab, setMainPanelDetailInitialTab] = useState<DetailTaskTab>("chat");
const boardScrollSnapshotRef = useRef<BoardScrollSnapshot | null>(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}

View File

@@ -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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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", () => {

View File

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

View File

@@ -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<string>([
"chatRooms",
"goalsView",
"insights",
"memoryView",
"roadmap",
"rightDock",
"skillsView",
"todoView",
"workflowColumns",
"workflowGraphExecutor",
"workflowInterpreterDualObserve",

View File

@@ -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 (
<section className="task-chat-group" key={`${item.role ?? "agent"}-${itemIndex}`} aria-label={t("taskChat.agentMessages", "{{label}} messages", { label: item.label })}>
<header className="task-chat-group-header">
<TaskChatAgentIcon label={item.label} modelInfo={modelInfo} role={item.role} />
<TaskChatAgentIcon label={item.label} modelInfo={modelInfo} />
<div>
<div className="task-chat-role-label">{item.label}</div>
<div className="task-chat-group-meta">

View File

@@ -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(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{}} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{}} />);
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 () => {

View File

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

View File

@@ -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;
}) => (
<div data-testid="board-view">
{tasks.map((task) => (
<button key={task.id} type="button" data-testid={`open-task-${task.id}`} onClick={() => onOpenDetail(task)}>
{task.title}
</button>
<div key={task.id}>
<button type="button" data-testid={`open-task-${task.id}`} onClick={() => onOpenDetail(task)}>
{task.title}
</button>
{task.modifiedFiles && task.modifiedFiles.length > 0 ? (
<button type="button" data-testid={`open-task-changes-${task.id}`} onClick={() => onOpenDetailWithTab?.(task, "changes")}>
Files changed
</button>
) : null}
</div>
))}
</div>
),
@@ -171,9 +186,11 @@ vi.mock("../../components/TaskDetailModal", () => ({
TaskDetailContent: ({
task,
onBackToBoard,
initialTab,
}: {
task: { id: string; title?: string };
onBackToBoard?: () => void;
initialTab?: string;
}) => (
<div data-testid="task-detail-main-panel-content">
{onBackToBoard && (
@@ -181,6 +198,7 @@ vi.mock("../../components/TaskDetailModal", () => ({
Back to board
</button>
)}
<p>tab:{initialTab ?? "chat"}</p>
<h2>{task.title ?? task.id}</h2>
</div>
),
@@ -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");

View File

@@ -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<string>(MODEL_LANE_KEYS);
const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
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<string, unknown>;
@@ -58,6 +174,31 @@ export interface SaveSplitResult {
projectPatch: Partial<Settings>;
}
function hasOwn(obj: object | null | undefined, key: string): boolean {
return !!obj && Object.prototype.hasOwnProperty.call(obj, key);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
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<string, unknown>)[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<string, unknown>)[key] = null;
} else if (value !== undefined) {
(globalPatch as Record<string, unknown>)[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<string, unknown>)[key] = null;
} else if (value !== undefined) {

View File

@@ -62,11 +62,11 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [prAuthAvailable, setPrAuthAvailable] = useState(false);
const [settingsLoaded, setSettingsLoaded] = useState(false);
const [experimentalFeatures, setExperimentalFeatures] = useState<Record<string, boolean>>({});
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]);