fix(FN-6951): preserve settings saves and inline file changes
This commit is contained in:
5
.changeset/settings-default-surfaces.md
Normal file
5
.changeset/settings-default-surfaces.md
Normal 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.
|
||||||
5
.changeset/task-card-files-changed-inline.md
Normal file
5
.changeset/task-card-files-changed-inline.md
Normal 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.
|
||||||
@@ -556,8 +556,12 @@ function AppInner() {
|
|||||||
/*
|
/*
|
||||||
FNXC:Navigation 2026-06-22-00:00:
|
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.
|
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 [mainPanelDetailTask, setMainPanelDetailTask] = useState<Task | TaskDetail | null>(null);
|
||||||
|
const [mainPanelDetailInitialTab, setMainPanelDetailInitialTab] = useState<DetailTaskTab>("chat");
|
||||||
const boardScrollSnapshotRef = useRef<BoardScrollSnapshot | null>(null);
|
const boardScrollSnapshotRef = useRef<BoardScrollSnapshot | null>(null);
|
||||||
const pendingBoardScrollRestoreRef = useRef(false);
|
const pendingBoardScrollRestoreRef = useRef(false);
|
||||||
|
|
||||||
@@ -1113,7 +1117,8 @@ function AppInner() {
|
|||||||
previousCapacityRiskTodoThresholdRef.current = capacityRiskTodoThreshold;
|
previousCapacityRiskTodoThresholdRef.current = capacityRiskTodoThreshold;
|
||||||
}, [settingsLoaded, capacityRiskBannerEnabled, capacityRiskTodoThreshold, currentProject?.id]);
|
}, [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 nodesEnabled = experimentalFeatures.nodesView === true;
|
||||||
const researchEnabled = experimentalFeatures.researchView === true;
|
const researchEnabled = experimentalFeatures.researchView === true;
|
||||||
const evalsEnabled = experimentalFeatures.evalsView === true;
|
const evalsEnabled = experimentalFeatures.evalsView === true;
|
||||||
@@ -1276,15 +1281,6 @@ function AppInner() {
|
|||||||
addToast,
|
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) => {
|
const handleOpenTaskLogs = useCallback(async (taskId: string) => {
|
||||||
try {
|
try {
|
||||||
const task = await fetchTaskDetail(taskId, currentProject?.id);
|
const task = await fetchTaskDetail(taskId, currentProject?.id);
|
||||||
@@ -1332,9 +1328,10 @@ function AppInner() {
|
|||||||
FNXC:Navigation 2026-06-22-00:00:
|
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.
|
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();
|
captureCurrentBoardScrollSnapshot();
|
||||||
setMainPanelDetailTask(task);
|
setMainPanelDetailTask(task);
|
||||||
|
setMainPanelDetailInitialTab(initialTab);
|
||||||
handleTaskViewChange("task-detail");
|
handleTaskViewChange("task-detail");
|
||||||
}, [captureCurrentBoardScrollSnapshot, handleTaskViewChange]);
|
}, [captureCurrentBoardScrollSnapshot, handleTaskViewChange]);
|
||||||
|
|
||||||
@@ -1342,9 +1339,19 @@ function AppInner() {
|
|||||||
const closeTaskDetailMainPanel = useCallback(() => {
|
const closeTaskDetailMainPanel = useCallback(() => {
|
||||||
pendingBoardScrollRestoreRef.current = true;
|
pendingBoardScrollRestoreRef.current = true;
|
||||||
setMainPanelDetailTask(null);
|
setMainPanelDetailTask(null);
|
||||||
|
setMainPanelDetailInitialTab("chat");
|
||||||
handleTaskViewChange("board");
|
handleTaskViewChange("board");
|
||||||
}, [handleTaskViewChange]);
|
}, [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:
|
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.
|
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}
|
projectId={currentProject?.id}
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
embedded
|
embedded
|
||||||
|
initialTab={mainPanelDetailInitialTab}
|
||||||
/*
|
/*
|
||||||
FNXC:TaskDetail 2026-06-22-18:40:
|
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.
|
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}
|
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. */
|
/* 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(); }}
|
onPopOut={(task) => { popOutTaskDetail(task); closeTaskDetailMainPanel(); }}
|
||||||
onOpenDetail={(value) => setMainPanelDetailTask(value)}
|
onOpenDetail={(value) => {
|
||||||
|
setMainPanelDetailTask(value);
|
||||||
|
setMainPanelDetailInitialTab("chat");
|
||||||
|
}}
|
||||||
onMoveTask={moveTask}
|
onMoveTask={moveTask}
|
||||||
onDeleteTask={deleteTask}
|
onDeleteTask={deleteTask}
|
||||||
onMergeTask={mergeTask}
|
onMergeTask={mergeTask}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* - one global + one project edit in a single session produce the expected
|
* - one global + one project edit in a single session produce the expected
|
||||||
* `updateGlobalSettings` / `updateSettings` patches with strict scope routing;
|
* `updateGlobalSettings` / `updateSettings` patches with strict scope routing;
|
||||||
* - clearing a project override emits null-as-delete;
|
* - 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);
|
* - untouched inherited project values are NOT written (changed-only gate);
|
||||||
* - explicit clears of global keys emit null, plain undefined is dropped.
|
* - explicit clears of global keys emit null, plain undefined is dropped.
|
||||||
*
|
*
|
||||||
@@ -62,6 +63,113 @@ describe("splitSettingsSave", () => {
|
|||||||
expect(projectPatch).toEqual({ maxConcurrent: 5 });
|
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)", () => {
|
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*
|
// 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
|
// value: a value equal to its initial override is not re-written. This is
|
||||||
@@ -190,9 +298,7 @@ describe("splitSettingsSave", () => {
|
|||||||
activeSection: "notifications",
|
activeSection: "notifications",
|
||||||
});
|
});
|
||||||
|
|
||||||
// undefined survives the object but is dropped by JSON.stringify on the wire;
|
expect(globalPatch).toEqual({});
|
||||||
// the patch must not coerce it to null when there was nothing to clear.
|
|
||||||
expect(globalPatch.ntfyTopic).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes githubTrackingDefaultRepo to global only on the global-general section", () => {
|
it("routes githubTrackingDefaultRepo to global only on the global-general section", () => {
|
||||||
|
|||||||
@@ -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");
|
const { t } = useTranslation("app");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
recordResumeEvent({
|
recordResumeEvent({
|
||||||
@@ -1035,7 +1035,8 @@ export function ChatView({ projectId, addToast, experimentalFeatures, floating =
|
|||||||
} = useChat(projectId, addToast);
|
} = useChat(projectId, addToast);
|
||||||
|
|
||||||
const [showNewDialog, setShowNewDialog] = useState(false);
|
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">(() => {
|
const [chatScope, setChatScope] = useState<"direct" | "rooms">(() => {
|
||||||
try {
|
try {
|
||||||
const persistedScope = localStorage.getItem(CHAT_SCOPE_STORAGE_KEY);
|
const persistedScope = localStorage.getItem(CHAT_SCOPE_STORAGE_KEY);
|
||||||
|
|||||||
@@ -297,10 +297,19 @@ The Roadmaps dashboard view and experiment were removed from the product surface
|
|||||||
|
|
||||||
FNXC:SettingsExperimental 2026-06-22-18:00:
|
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.
|
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>([
|
const HIDDEN_EXPERIMENTAL_FEATURE_KEYS = new Set<string>([
|
||||||
|
"chatRooms",
|
||||||
|
"goalsView",
|
||||||
|
"insights",
|
||||||
|
"memoryView",
|
||||||
"roadmap",
|
"roadmap",
|
||||||
"rightDock",
|
"rightDock",
|
||||||
|
"skillsView",
|
||||||
|
"todoView",
|
||||||
"workflowColumns",
|
"workflowColumns",
|
||||||
"workflowGraphExecutor",
|
"workflowGraphExecutor",
|
||||||
"workflowInterpreterDualObserve",
|
"workflowInterpreterDualObserve",
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ function getModelForRole(
|
|||||||
return getExplicitModelForRole(task, role) ?? getRuntimeModelForRole(entries, role) ?? getEffectiveModelForRole(effectiveModels, role);
|
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) {
|
if (modelInfo?.provider) {
|
||||||
const title = modelInfo.modelId ? `${label}: ${modelInfo.provider}/${modelInfo.modelId}` : `${label}: ${modelInfo.provider}`;
|
const title = modelInfo.modelId ? `${label}: ${modelInfo.provider}/${modelInfo.modelId}` : `${label}: ${modelInfo.provider}`;
|
||||||
return (
|
return (
|
||||||
@@ -889,7 +889,7 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on
|
|||||||
return (
|
return (
|
||||||
<section className="task-chat-group" key={`${item.role ?? "agent"}-${itemIndex}`} aria-label={t("taskChat.agentMessages", "{{label}} messages", { label: item.label })}>
|
<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">
|
<header className="task-chat-group-header">
|
||||||
<TaskChatAgentIcon label={item.label} modelInfo={modelInfo} role={item.role} />
|
<TaskChatAgentIcon label={item.label} modelInfo={modelInfo} />
|
||||||
<div>
|
<div>
|
||||||
<div className="task-chat-role-label">{item.label}</div>
|
<div className="task-chat-role-label">{item.label}</div>
|
||||||
<div className="task-chat-group-meta">
|
<div className="task-chat-group-meta">
|
||||||
|
|||||||
@@ -3566,12 +3566,12 @@ describe("Direct/Rooms scope toggle", () => {
|
|||||||
localStorage.clear();
|
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: [] });
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
||||||
|
|
||||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{}} />);
|
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();
|
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: [] });
|
setupMockChat({ sessions: [], filteredSessions: [] });
|
||||||
localStorage.setItem("fusion:chat-scope", "rooms");
|
localStorage.setItem("fusion:chat-scope", "rooms");
|
||||||
|
|
||||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{}} />);
|
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")).toHaveAttribute("aria-selected", "true");
|
||||||
expect(screen.getByTestId("chat-search-input")).toBeInTheDocument();
|
expect(screen.getByTestId("chat-sidebar-rooms-empty")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists scope in localStorage and restores Rooms on next mount", async () => {
|
it("persists scope in localStorage and restores Rooms on next mount", async () => {
|
||||||
|
|||||||
@@ -3842,8 +3842,8 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
await openExperimentalFeaturesSection();
|
await openExperimentalFeaturesSection();
|
||||||
|
|
||||||
// Known features are always shown even with no custom features configured.
|
// Known features that remain experimental are shown even with no custom features configured.
|
||||||
expect(screen.getByText("Insights")).toBeInTheDocument();
|
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.
|
// 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();
|
expect(screen.queryByText("Roadmaps")).not.toBeInTheDocument();
|
||||||
|
|
||||||
@@ -3851,7 +3851,6 @@ describe("SettingsModal", () => {
|
|||||||
"Research View",
|
"Research View",
|
||||||
"Evals View",
|
"Evals View",
|
||||||
"Subtask Breakdown",
|
"Subtask Breakdown",
|
||||||
"Chat Rooms",
|
|
||||||
"Sandbox (command isolation)",
|
"Sandbox (command isolation)",
|
||||||
"Planning-style Agent Onboarding",
|
"Planning-style Agent Onboarding",
|
||||||
]) {
|
]) {
|
||||||
@@ -4047,12 +4046,13 @@ describe("SettingsModal", () => {
|
|||||||
it("does not emit legacy alias null deletes when canonical key is absent", async () => {
|
it("does not emit legacy alias null deletes when canonical key is absent", async () => {
|
||||||
mockFetchSettings.mockResolvedValue({
|
mockFetchSettings.mockResolvedValue({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
experimentalFeatures: { insights: true },
|
experimentalFeatures: { "my-feature": false },
|
||||||
});
|
});
|
||||||
|
|
||||||
renderModal();
|
renderModal();
|
||||||
|
|
||||||
await openExperimentalFeaturesSection();
|
await openExperimentalFeaturesSection();
|
||||||
|
await userEvent.click(screen.getByLabelText("my-feature"));
|
||||||
|
|
||||||
await userEvent.click(screen.getByText("Save"));
|
await userEvent.click(screen.getByText("Save"));
|
||||||
|
|
||||||
@@ -4061,7 +4061,7 @@ describe("SettingsModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const payload = mockUpdateGlobalSettings.mock.calls[0][0];
|
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();
|
expect(payload.experimentalFeatures.devServer).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4073,6 +4073,7 @@ describe("SettingsModal", () => {
|
|||||||
workflowGraphExecutor: false,
|
workflowGraphExecutor: false,
|
||||||
workflowInterpreterDualObserve: true,
|
workflowInterpreterDualObserve: true,
|
||||||
insights: true,
|
insights: true,
|
||||||
|
"my-feature": false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4083,6 +4084,9 @@ describe("SettingsModal", () => {
|
|||||||
expect(screen.queryByText("workflowColumns")).not.toBeInTheDocument();
|
expect(screen.queryByText("workflowColumns")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("workflowGraphExecutor")).not.toBeInTheDocument();
|
expect(screen.queryByText("workflowGraphExecutor")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText(/dual-observe parity/i)).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"));
|
await userEvent.click(screen.getByText("Save"));
|
||||||
|
|
||||||
@@ -4099,6 +4103,7 @@ describe("SettingsModal", () => {
|
|||||||
workflowGraphExecutor: false,
|
workflowGraphExecutor: false,
|
||||||
workflowInterpreterDualObserve: true,
|
workflowInterpreterDualObserve: true,
|
||||||
insights: true,
|
insights: true,
|
||||||
|
"my-feature": true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4245,8 +4250,9 @@ describe("SettingsModal", () => {
|
|||||||
|
|
||||||
await openExperimentalFeaturesSection();
|
await openExperimentalFeaturesSection();
|
||||||
|
|
||||||
// Known features should always be shown regardless of settings
|
// Known features that remain experimental should always be shown regardless of settings.
|
||||||
expect(screen.getByText("Insights")).toBeInTheDocument();
|
expect(screen.getByText("Dev Server")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Insights")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Roadmaps")).not.toBeInTheDocument();
|
expect(screen.queryByText("Roadmaps")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -146,12 +146,27 @@ vi.mock("../../components/model-onboarding-state", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../components/Board", () => ({
|
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">
|
<div data-testid="board-view">
|
||||||
{tasks.map((task) => (
|
{tasks.map((task) => (
|
||||||
<button key={task.id} type="button" data-testid={`open-task-${task.id}`} onClick={() => onOpenDetail(task)}>
|
<div key={task.id}>
|
||||||
{task.title}
|
<button type="button" data-testid={`open-task-${task.id}`} onClick={() => onOpenDetail(task)}>
|
||||||
</button>
|
{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>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -171,9 +186,11 @@ vi.mock("../../components/TaskDetailModal", () => ({
|
|||||||
TaskDetailContent: ({
|
TaskDetailContent: ({
|
||||||
task,
|
task,
|
||||||
onBackToBoard,
|
onBackToBoard,
|
||||||
|
initialTab,
|
||||||
}: {
|
}: {
|
||||||
task: { id: string; title?: string };
|
task: { id: string; title?: string };
|
||||||
onBackToBoard?: () => void;
|
onBackToBoard?: () => void;
|
||||||
|
initialTab?: string;
|
||||||
}) => (
|
}) => (
|
||||||
<div data-testid="task-detail-main-panel-content">
|
<div data-testid="task-detail-main-panel-content">
|
||||||
{onBackToBoard && (
|
{onBackToBoard && (
|
||||||
@@ -181,6 +198,7 @@ vi.mock("../../components/TaskDetailModal", () => ({
|
|||||||
Back to board
|
Back to board
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<p>tab:{initialTab ?? "chat"}</p>
|
||||||
<h2>{task.title ?? task.id}</h2>
|
<h2>{task.title ?? task.id}</h2>
|
||||||
</div>
|
</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
|
// 5. Verify useNavigationHistory is called with enabled=true on mobile
|
||||||
it("calls useNavigationHistory with enabled=true on mobile", async () => {
|
it("calls useNavigationHistory with enabled=true on mobile", async () => {
|
||||||
mockUseViewportMode.mockReturnValue("mobile");
|
mockUseViewportMode.mockReturnValue("mobile");
|
||||||
|
|||||||
@@ -8,11 +8,14 @@
|
|||||||
* 1. Global keys are routed via {@link isGlobalSettingsKey} to the global
|
* 1. Global keys are routed via {@link isGlobalSettingsKey} to the global
|
||||||
* patch; project keys via {@link isProjectSettingsKey} to the project
|
* patch; project keys via {@link isProjectSettingsKey} to the project
|
||||||
* patch. (A key can be neither — server-only/UI-only fields are dropped.)
|
* 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
|
* initial value was defined) is written as `null` so it survives
|
||||||
* `JSON.stringify` and tells the server to delete the key. Plain
|
* `JSON.stringify` and tells the server to delete the key. Plain
|
||||||
* `undefined` is dropped.
|
* `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 —
|
* the user never touched is NOT serialized as an explicit override —
|
||||||
* doing so would silently break inheritance for every project setting on
|
* doing so would silently break inheritance for every project setting on
|
||||||
* every save. Only keys whose value differs from the initial project-scoped
|
* 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 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 {
|
export interface SaveSplitInput {
|
||||||
/** The fully-normalized form payload (after trimming/normalization). */
|
/** The fully-normalized form payload (after trimming/normalization). */
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
@@ -58,6 +174,31 @@ export interface SaveSplitResult {
|
|||||||
projectPatch: Partial<Settings>;
|
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,
|
* Split a normalized settings form payload into global and project patches,
|
||||||
* preserving null-as-delete and changed-only-project-write semantics.
|
* preserving null-as-delete and changed-only-project-write semantics.
|
||||||
@@ -85,11 +226,37 @@ export function splitSettingsSave({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isGlobalSettingsKey(key)) {
|
if (isGlobalSettingsKey(key)) {
|
||||||
// null-as-delete: explicit clear is sent as null, plain undefined dropped.
|
/*
|
||||||
const initialValue = initialValues?.[key as keyof GlobalSettings];
|
FNXC:SettingsPersistence 2026-06-23-00:55:
|
||||||
if (value === undefined && initialValue !== undefined) {
|
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;
|
(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;
|
(globalPatch as Record<string, unknown>)[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +272,7 @@ export function splitSettingsSave({
|
|||||||
const initialProjectValue = initialScopedValues?.project?.[key as keyof Settings];
|
const initialProjectValue = initialScopedValues?.project?.[key as keyof Settings];
|
||||||
|
|
||||||
if (MODEL_LANE_KEY_SET.has(key)) {
|
if (MODEL_LANE_KEY_SET.has(key)) {
|
||||||
if (value !== initialProjectValue) {
|
if (!settingsValueEquals(value, initialProjectValue)) {
|
||||||
if (
|
if (
|
||||||
(value === undefined || value === null) &&
|
(value === undefined || value === null) &&
|
||||||
initialProjectValue !== undefined &&
|
initialProjectValue !== undefined &&
|
||||||
@@ -118,7 +285,7 @@ export function splitSettingsSave({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Changed-only gate + null-as-delete for non-model project settings.
|
// 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) {
|
if (value === undefined && initialProjectValue !== undefined && initialProjectValue !== null) {
|
||||||
(projectPatch as Record<string, unknown>)[key] = null;
|
(projectPatch as Record<string, unknown>)[key] = null;
|
||||||
} else if (value !== undefined) {
|
} else if (value !== undefined) {
|
||||||
|
|||||||
@@ -62,11 +62,11 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
|||||||
const [prAuthAvailable, setPrAuthAvailable] = useState(false);
|
const [prAuthAvailable, setPrAuthAvailable] = useState(false);
|
||||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||||
const [experimentalFeatures, setExperimentalFeatures] = useState<Record<string, boolean>>({});
|
const [experimentalFeatures, setExperimentalFeatures] = useState<Record<string, boolean>>({});
|
||||||
const [insightsEnabled, setInsightsEnabled] = useState(false);
|
const [insightsEnabled, setInsightsEnabled] = useState(true);
|
||||||
const [memoryEnabled, setMemoryEnabled] = useState(false);
|
const [memoryEnabled, setMemoryEnabled] = useState(true);
|
||||||
const [devServerEnabled, setDevServerEnabled] = useState(false);
|
const [devServerEnabled, setDevServerEnabled] = useState(false);
|
||||||
const [todosEnabled, setTodosEnabled] = useState(false);
|
const [todosEnabled, setTodosEnabled] = useState(true);
|
||||||
const [goalsEnabled, setGoalsEnabled] = useState(false);
|
const [goalsEnabled, setGoalsEnabled] = useState(true);
|
||||||
const [autoReloadOnVersionChange, setAutoReloadOnVersionChangeState] = useState(true);
|
const [autoReloadOnVersionChange, setAutoReloadOnVersionChangeState] = useState(true);
|
||||||
const autoMergeRef = useRef(autoMerge);
|
const autoMergeRef = useRef(autoMerge);
|
||||||
|
|
||||||
@@ -112,11 +112,15 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
|||||||
setCapacityRiskTodoThreshold(settings.capacityRiskTodoThreshold ?? 20);
|
setCapacityRiskTodoThreshold(settings.capacityRiskTodoThreshold ?? 20);
|
||||||
setExperimentalFeatures(settings.experimentalFeatures ?? {});
|
setExperimentalFeatures(settings.experimentalFeatures ?? {});
|
||||||
const features = 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);
|
setDevServerEnabled(features.devServerView === true || features.devServer === true);
|
||||||
setTodosEnabled(features.todoView === true);
|
setTodosEnabled(true);
|
||||||
setGoalsEnabled(features.goalsView === true);
|
setGoalsEnabled(true);
|
||||||
// Sync the module-level auto-reload guard with the persisted setting
|
// Sync the module-level auto-reload guard with the persisted setting
|
||||||
const autoReload = settings.autoReloadOnVersionChange !== false;
|
const autoReload = settings.autoReloadOnVersionChange !== false;
|
||||||
setAutoReloadOnVersionChangeState(autoReload);
|
setAutoReloadOnVersionChangeState(autoReload);
|
||||||
@@ -129,11 +133,11 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSettingsLoaded(false);
|
setSettingsLoaded(false);
|
||||||
setExperimentalFeatures({});
|
setExperimentalFeatures({});
|
||||||
setInsightsEnabled(false);
|
setInsightsEnabled(true);
|
||||||
setMemoryEnabled(false);
|
setMemoryEnabled(true);
|
||||||
setDevServerEnabled(false);
|
setDevServerEnabled(false);
|
||||||
setTodosEnabled(false);
|
setTodosEnabled(true);
|
||||||
setGoalsEnabled(false);
|
setGoalsEnabled(true);
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user