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:
|
||||
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}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user