FN-7261: add global modal dismissal setting

Add a global dashboard preference that keeps modal backdrop dismissal off by default while allowing operators to opt in.

- Add dismissModalsOnOutsideClick to global settings defaults, schema, docs, and save handling.
- Provide the modal dismissal preference through App and gate shared overlay mouse/touch dismissal on it.
- Expose the preference in Global General settings and update modal dismissal tests.
- Add a changeset for the published CLI package.

Files changed:
 .changeset/fn-7261-global-modal-outside-dismiss.md |  7 ++++
 docs/settings-reference.md                         |  1 +
 .../core/src/__tests__/settings-defaults.test.ts   |  9 +++++
 packages/core/src/settings-schema.ts               |  5 +++
 packages/core/src/types.ts                         |  5 +++
 packages/dashboard/app/App.tsx                     | 16 +++++----
 .../app/components/AgentErrorDetailsModal.tsx      |  4 ++-
 .../__tests__/AgentErrorDetailsModal.test.tsx      | 28 +++++++++++++++-
 .../__tests__/SettingsModal.general.test.tsx       | 23 ++++++++++++-
 .../app/components/settings/save-split.ts          |  1 +
 .../settings/sections/GlobalGeneralSection.tsx     |  5 +++
 .../app/hooks/__tests__/useOverlayDismiss.test.tsx | 38 ++++++++++++++++++----
 packages/dashboard/app/hooks/useAppSettings.ts     |  5 +++
 packages/dashboard/app/hooks/useOverlayDismiss.ts  | 36 +++++++++++++++++---
 14 files changed, 163 insertions(+), 20 deletions(-)

Fusion-Task-Id: FN-7261

Fusion-Task-Lineage: 4fd5195f-c759-46b4-b567-0d5a9d5b92e3

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 01:50:52 -07:00
parent 04f06e6b70
commit ebb805d253
14 changed files with 163 additions and 20 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a global setting to control modal backdrop dismissal.
category: feature
dev: Adds dismissModalsOnOutsideClick as a global-only dashboard preference, defaulting false.

View File

@@ -52,6 +52,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
| `shadcnCustomColors` | `Record<string, string>` | `undefined` | Optional shadcn design-token override map for `"shadcn-custom"` only. Keys are CSS token names such as `--accent`, `--bg`, `--surface`, `--card`, `--border`, `--text`, `--text-muted`, workflow status tokens, and `--color-success`/`--color-warning`/`--color-error`; values must be sanitized `#RGB` or `#RRGGBB` hex colors. Missing or invalid entries fall back to the `shadcn-custom` base defaults and are not applied to other themes. |
| `language` | `"en" \| "zh-CN" \| "zh-TW" \| "fr" \| "es" \| "ko"` | `undefined` | UI language for the dashboard and TUI. When unset, the dashboard detects from localStorage → browser language and the CLI from `--lang` flag → environment locale, falling back to `en`. Validated at the store write boundary (`validateLocale`); invalid values are dropped. Reset to auto-detect via the dashboard's "Auto" language option or `fn settings set language auto` (clears the persisted key). |
| `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. |
| `dismissModalsOnOutsideClick` | `boolean` | `false` | Global dashboard preference for closing fixed modal overlays by clicking/tapping the backdrop. Off by default to prevent accidental modal dismissal; explicit close, cancel, and Escape paths remain available. |
| `defaultProvider` | `string` | `undefined` | Default AI provider. |
| `defaultModelId` | `string` | `undefined` | Default AI model ID. |
| `modelPricingOverrides` | `Record<string, ModelPricing>` | `undefined` | Optional global Command Center pricing overrides keyed by lowercased `provider:model` or bare `:model`. Values store USD per 1M input, output, cache-read, and cache-write tokens plus optional `source`; they override the built-in pricing table for cost estimates only and are editable from Settings → Global Models → View pricing table. |

View File

@@ -176,6 +176,15 @@ describe("settings defaults invariants", () => {
});
});
describe("dismissModalsOnOutsideClick default", () => {
it("defaults modal backdrop dismissal off and global-scoped only", () => {
expect(DEFAULT_GLOBAL_SETTINGS.dismissModalsOnOutsideClick).toBe(false);
expect(GLOBAL_SETTINGS_KEYS).toContain("dismissModalsOnOutsideClick");
expect("dismissModalsOnOutsideClick" in DEFAULT_PROJECT_SETTINGS).toBe(false);
expect(PROJECT_SETTINGS_KEYS).not.toContain("dismissModalsOnOutsideClick");
});
});
describe("mergeIntegrationWorktree default", () => {
it("defaults project settings to reuse-task-worktree", () => {
expect(DEFAULT_PROJECT_SETTINGS.mergeIntegrationWorktree).toBe("reuse-task-worktree");

View File

@@ -71,6 +71,11 @@ export const DEFAULT_GLOBAL_SETTINGS = {
colorTheme: "ocean",
shadcnCustomColors: undefined,
dashboardFontScalePct: 100,
/*
FNXC:ModalDismissal 2026-06-29-00:00:
Fixed dashboard modals must ignore backdrop clicks by default so accidental outside taps do not discard in-progress form state. Operators can globally opt in to the legacy outside-click dismissal behavior.
*/
dismissModalsOnOutsideClick: false,
language: undefined,
defaultProvider: undefined,
defaultModelId: undefined,

View File

@@ -2954,6 +2954,11 @@ export interface GlobalSettings {
shadcnCustomColors?: Record<string, string>;
/** Dashboard font size scale percentage. Bounded to 85-125. Default: 100. */
dashboardFontScalePct?: number;
/**
* FNXC:ModalDismissal 2026-06-29-00:00:
* Modal backdrop dismissal is a global operator preference, not project policy. Default false keeps fixed modal overlays from closing on accidental outside clicks unless the operator opts in.
*/
dismissModalsOnOutsideClick?: boolean;
/** Active UI locale (e.g. `"en"`, `"zh-CN"`, `"fr"`). One of `SUPPORTED_LOCALES`.
* When unset, each surface resolves the locale at runtime (browser/env
* detection) and falls back to `DEFAULT_LOCALE` ("en"). */

View File

@@ -39,6 +39,7 @@ import { ConfirmDialogProvider } from "./hooks/useConfirm";
import { useTheme } from "./hooks/useTheme";
import { useModalManager, type DetailTaskOrigin, type DetailTaskTab } from "./hooks/useModalManager";
import { useAppSettings } from "./hooks/useAppSettings";
import { ModalDismissPreferenceProvider } from "./hooks/useOverlayDismiss";
import { useDeepLink } from "./hooks/useDeepLink";
import { useFavorites } from "./hooks/useFavorites";
import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
@@ -560,6 +561,7 @@ function AppInner() {
openMobileTasksInPopup,
quickChatButtonMode,
quickChatCloseOnOutsideClick,
dismissModalsOnOutsideClick,
maxTotalRetriesBeforeFail,
prAuthAvailable,
settingsLoaded,
@@ -1372,9 +1374,10 @@ function AppInner() {
setShowGitHubStarPrompt,
};
return (
<NavigationHistoryProvider value={{ pushNav, replaceCurrent, removeNav }}>
<FileBrowserProvider openFile={openFileInBrowser}>
<RetryWarningProvider value={maxTotalRetriesBeforeFail * RETRY_WARNING_RATIO}>
<ModalDismissPreferenceProvider enabled={dismissModalsOnOutsideClick}>
<NavigationHistoryProvider value={{ pushNav, replaceCurrent, removeNav }}>
<FileBrowserProvider openFile={openFileInBrowser}>
<RetryWarningProvider value={maxTotalRetriesBeforeFail * RETRY_WARNING_RATIO}>
{isFirstEverBoot ? (
<>
<DashboardLoader stage={loadingStage} />
@@ -1702,9 +1705,10 @@ function AppInner() {
)}
</>
)}
</RetryWarningProvider>
</FileBrowserProvider>
</NavigationHistoryProvider>
</RetryWarningProvider>
</FileBrowserProvider>
</NavigationHistoryProvider>
</ModalDismissPreferenceProvider>
);
}

View File

@@ -2,6 +2,7 @@ import "./AgentErrorDetailsModal.css";
import { useMemo, useState } from "react";
import { AlertCircle, Check, Copy, ExternalLink } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
const DEFAULT_ISSUE_URL = "https://github.com/Runfusion/Fusion/issues/new";
@@ -53,13 +54,14 @@ export function AgentErrorDetailsModal({ open, onClose, errorText, issueContext
const [copied, setCopied] = useState(false);
const { t } = useTranslation("app");
const issueUrl = useMemo(() => buildAgentErrorIssueUrl(errorText, issueContext), [errorText, issueContext]);
const overlayDismissProps = useOverlayDismiss(onClose);
if (!open) {
return null;
}
return (
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && onClose()} role="dialog" aria-modal="true" aria-label={t("agentError.dialogLabel", "Agent error details")}>
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true" aria-label={t("agentError.dialogLabel", "Agent error details")}>
<div className="modal agent-error-modal">
<div className="modal-header">
<h2 className="modal-title">

View File

@@ -1,8 +1,9 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AgentErrorDetailsModal, AgentErrorIndicator } from "../AgentErrorDetailsModal";
import { loadAllAppCss } from "../../test/cssFixture";
import { ModalDismissPreferenceProvider } from "../../hooks/useOverlayDismiss";
const issueContext = {
surface: "AgentsView",
@@ -51,6 +52,31 @@ describe("AgentErrorDetailsModal", () => {
});
});
it("gates backdrop dismissal behind the global modal dismiss preference", () => {
const disabledClose = vi.fn();
const { unmount } = render(<AgentErrorDetailsModal open={true} onClose={disabledClose} errorText="boom" issueContext={issueContext} />);
const disabledOverlay = screen.getByRole("dialog", { name: "Agent error details" });
fireEvent.mouseDown(disabledOverlay);
fireEvent.mouseUp(disabledOverlay);
expect(disabledClose).not.toHaveBeenCalled();
unmount();
const enabledClose = vi.fn();
render(
<ModalDismissPreferenceProvider enabled>
<AgentErrorDetailsModal open={true} onClose={enabledClose} errorText="boom" issueContext={issueContext} />
</ModalDismissPreferenceProvider>,
);
const enabledOverlay = screen.getByRole("dialog", { name: "Agent error details" });
fireEvent.mouseDown(enabledOverlay);
fireEvent.mouseUp(enabledOverlay);
expect(enabledClose).toHaveBeenCalledTimes(1);
});
it("opens github report link", async () => {
const user = userEvent.setup();
render(<AgentErrorDetailsModal open={true} onClose={vi.fn()} errorText="report me" issueContext={issueContext} />);

View File

@@ -513,7 +513,9 @@ describe("SettingsModal", () => {
renderModal({ initialSection: "global-general" });
await waitForSettingsModalReady();
// persistAgentToolOutput defaults to unchecked; Star-on-GitHub control absent.
// Global modal outside-dismiss and persistAgentToolOutput default to unchecked; Star-on-GitHub control absent.
expect(screen.getByRole("checkbox", { name: "Dismiss modals by clicking outside" })).not.toBeChecked();
expect(screen.getByText(/Off by default to prevent accidental dismissal/i).closest("small")).toBeTruthy();
expect(screen.getByRole("checkbox", { name: "Save tool output in agent logs" })).not.toBeChecked();
expect(screen.queryByRole("checkbox", { name: /Show "Star on GitHub" button in Settings header/i })).toBeNull();
@@ -582,6 +584,25 @@ describe("SettingsModal", () => {
expect(screen.getByRole("checkbox", { name: "Save AI thinking for ephemeral / task-worker agents" })).toBeChecked();
});
it("saves modal outside-dismiss only via global settings payload", async () => {
renderModal({ initialSection: "global-general" });
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByRole("checkbox", { name: "Dismiss modals by clicking outside" }));
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
});
const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record<string, unknown>;
expect(globalPayload.dismissModalsOnOutsideClick).toBe(true);
if (mockUpdateSettings.mock.calls.length > 0) {
const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record<string, unknown>;
expect(projectPayload.dismissModalsOnOutsideClick).toBeUndefined();
}
});
it("saves persistAgentToolOutput only via global settings payload", async () => {
renderModal({ initialSection: "global-general" });
await waitForSettingsModalReady();

View File

@@ -71,6 +71,7 @@ const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
"global-general": new Set([
"githubTrackingDefaultRepo",
"language",
"dismissModalsOnOutsideClick",
"persistAgentToolOutput",
"persistAgentThinkingLogPermanent",
"persistAgentThinkingLogEphemeral",

View File

@@ -21,6 +21,11 @@ export function GlobalGeneralSection({ scopeBanner, form, setForm, globalTrackin
<small>{t("settings.globalGeneral.projectsInheritThisValueWhenTheyDoNot", "Projects inherit this value when they do not set a project default tracking repo.")}</small>
</div>
<CliBinaryPanel />
<div className="form-group">
<label htmlFor="dismissModalsOnOutsideClick" className="checkbox-label">
<input id="dismissModalsOnOutsideClick" type="checkbox" checked={form.dismissModalsOnOutsideClick === true} onChange={(e) => setForm((f) => ({ ...f, dismissModalsOnOutsideClick: e.target.checked }))}/>{t("settings.globalGeneral.dismissModalsByClickingOutside", " Dismiss modals by clicking outside ")}</label>
<small>{t("settings.globalGeneral.dismissModalsByClickingOutsideHint", " When enabled, clicking or tapping a modal backdrop closes the modal. Off by default to prevent accidental dismissal. ")}</small>
</div>
<div className="form-group">
<label htmlFor="persistAgentToolOutput" className="checkbox-label">
<input id="persistAgentToolOutput" type="checkbox" checked={form.persistAgentToolOutput === true} onChange={(e) => setForm((f) => ({ ...f, persistAgentToolOutput: e.target.checked }))}/>{t("settings.globalGeneral.saveToolOutputInAgentLogs", " Save tool output in agent logs ")}</label>

View File

@@ -1,9 +1,9 @@
import { describe, it, expect, vi } from "vitest";
import { render, fireEvent } from "@testing-library/react";
import { useOverlayDismiss } from "../useOverlayDismiss";
import { ModalDismissPreferenceProvider, useOverlayDismiss } from "../useOverlayDismiss";
function OverlayHarness({ onClose }: { onClose: () => void }) {
const props = useOverlayDismiss(onClose);
function OverlayHarness({ onClose, enabled }: { onClose: () => void; enabled?: boolean }) {
const props = useOverlayDismiss(onClose, enabled === undefined ? undefined : { enabled });
return (
<div data-testid="overlay" {...props}>
<div data-testid="modal-content">content</div>
@@ -12,7 +12,7 @@ function OverlayHarness({ onClose }: { onClose: () => void }) {
}
describe("useOverlayDismiss", () => {
it("closes on real overlay mouse down/up", () => {
it("does not close on real overlay mouse down/up when the global setting is disabled by default", () => {
const onClose = vi.fn();
const { getByTestId } = render(<OverlayHarness onClose={onClose} />);
const overlay = getByTestId("overlay");
@@ -20,12 +20,23 @@ describe("useOverlayDismiss", () => {
fireEvent.mouseDown(overlay);
fireEvent.mouseUp(overlay);
expect(onClose).toHaveBeenCalledTimes(0);
});
it("closes on real overlay mouse down/up when enabled", () => {
const onClose = vi.fn();
const { getByTestId } = render(<OverlayHarness onClose={onClose} enabled />);
const overlay = getByTestId("overlay");
fireEvent.mouseDown(overlay);
fireEvent.mouseUp(overlay);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("ignores compatibility mouse sequence immediately after touch", () => {
const onClose = vi.fn();
const { getByTestId } = render(<OverlayHarness onClose={onClose} />);
const { getByTestId } = render(<OverlayHarness onClose={onClose} enabled />);
const overlay = getByTestId("overlay");
fireEvent.touchStart(overlay);
@@ -38,7 +49,7 @@ describe("useOverlayDismiss", () => {
it("does not close when mouse starts inside modal and ends on overlay", () => {
const onClose = vi.fn();
const { getByTestId } = render(<OverlayHarness onClose={onClose} />);
const { getByTestId } = render(<OverlayHarness onClose={onClose} enabled />);
const overlay = getByTestId("overlay");
const modal = getByTestId("modal-content");
@@ -47,4 +58,19 @@ describe("useOverlayDismiss", () => {
expect(onClose).toHaveBeenCalledTimes(0);
});
it("uses the modal dismiss preference provider when no per-call override is supplied", () => {
const onClose = vi.fn();
const { getByTestId } = render(
<ModalDismissPreferenceProvider enabled>
<OverlayHarness onClose={onClose} />
</ModalDismissPreferenceProvider>,
);
const overlay = getByTestId("overlay");
fireEvent.mouseDown(overlay);
fireEvent.mouseUp(overlay);
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -24,6 +24,7 @@ export interface UseAppSettingsResult {
openMobileTasksInPopup: boolean;
quickChatButtonMode: QuickChatButtonMode;
quickChatCloseOnOutsideClick: boolean;
dismissModalsOnOutsideClick: boolean;
showQuickChatFAB: boolean;
maxTotalRetriesBeforeFail: number;
prAuthAvailable: boolean;
@@ -65,6 +66,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [openMobileTasksInPopup, setOpenMobileTasksInPopup] = useState(false);
const [quickChatButtonMode, setQuickChatButtonMode] = useState<QuickChatButtonMode>("off");
const [quickChatCloseOnOutsideClick, setQuickChatCloseOnOutsideClick] = useState(true);
const [dismissModalsOnOutsideClick, setDismissModalsOnOutsideClick] = useState(false);
const [showQuickChatFAB, setShowQuickChatFAB] = useState(false);
const [maxTotalRetriesBeforeFail, setMaxTotalRetriesBeforeFail] = useState(25);
const [prAuthAvailable, setPrAuthAvailable] = useState(false);
@@ -116,6 +118,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
: "off";
setQuickChatButtonMode(nextQuickChatButtonMode);
setQuickChatCloseOnOutsideClick(settings.quickChatCloseOnOutsideClick !== false);
setDismissModalsOnOutsideClick(settings.dismissModalsOnOutsideClick === true);
setShowQuickChatFAB(nextQuickChatButtonMode === "floating");
setMaxTotalRetriesBeforeFail(settings.maxTotalRetriesBeforeFail ?? 25);
setCapacityRiskBannerEnabled(settings.capacityRiskBannerEnabled === true);
@@ -151,6 +154,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setOpenTasksInRightSidebar(false);
setOpenMobileTasksInPopup(false);
setQuickChatCloseOnOutsideClick(true);
setDismissModalsOnOutsideClick(false);
setTodosEnabled(true);
setGoalsEnabled(true);
void refresh();
@@ -254,6 +258,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
openMobileTasksInPopup,
quickChatButtonMode,
quickChatCloseOnOutsideClick,
dismissModalsOnOutsideClick,
showQuickChatFAB,
maxTotalRetriesBeforeFail,
prAuthAvailable,

View File

@@ -1,4 +1,20 @@
import { useCallback, useEffect, useRef } from "react";
import { createContext, createElement, useCallback, useContext, useEffect, useRef, type ReactNode } from "react";
const ModalDismissPreferenceContext = createContext(false);
export function ModalDismissPreferenceProvider({
enabled,
children,
}: {
enabled: boolean;
children: ReactNode;
}) {
return createElement(ModalDismissPreferenceContext.Provider, { value: enabled }, children);
}
export function useModalDismissPreference(): boolean {
return useContext(ModalDismissPreferenceContext);
}
/**
* Returns props for a modal-overlay element that dismisses only when a real
@@ -15,12 +31,18 @@ import { useCallback, useEffect, useRef } from "react";
* does NOT need to stopPropagation — mousedown on the modal sets the ref to
* `false`, so the overlay's mouseup handler bails.
*/
export function useOverlayDismiss(onClose: () => void): {
export function useOverlayDismiss(onClose: () => void, options?: { enabled?: boolean }): {
onMouseDown: (e: React.MouseEvent) => void;
onMouseUp: (e: React.MouseEvent) => void;
onTouchStart: () => void;
onTouchEnd: () => void;
} {
const contextEnabled = useModalDismissPreference();
/*
FNXC:ModalDismissal 2026-06-29-00:00:
Modal backdrop dismissal is globally default-off to prevent accidental data loss. Components using this helper keep explicit close, cancel, and Escape paths, while backdrop mouse/touch dismissal only runs when the global preference is enabled.
*/
const dismissEnabled = options?.enabled ?? contextEnabled;
const startedOnOverlayRef = useRef(false);
const lastTouchAtRef = useRef(0);
@@ -29,6 +51,10 @@ export function useOverlayDismiss(onClose: () => void): {
}, []);
const onMouseDown = useCallback((e: React.MouseEvent) => {
if (!dismissEnabled) {
startedOnOverlayRef.current = false;
return;
}
// Android/webview may emit compatibility mouse events right after touchend.
// Ignore those so a newly-mounted overlay is not dismissed immediately.
if (Date.now() - lastTouchAtRef.current < 500) {
@@ -36,7 +62,7 @@ export function useOverlayDismiss(onClose: () => void): {
return;
}
startedOnOverlayRef.current = e.target === e.currentTarget;
}, []);
}, [dismissEnabled]);
useEffect(() => {
if (typeof document === "undefined") return;
@@ -56,11 +82,11 @@ export function useOverlayDismiss(onClose: () => void): {
const onMouseUp = useCallback(
(e: React.MouseEvent) => {
const shouldClose = startedOnOverlayRef.current && e.target === e.currentTarget;
const shouldClose = dismissEnabled && startedOnOverlayRef.current && e.target === e.currentTarget;
startedOnOverlayRef.current = false;
if (shouldClose) onClose();
},
[onClose],
[dismissEnabled, onClose],
);
return { onMouseDown, onMouseUp, onTouchStart: markTouch, onTouchEnd: markTouch };