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:
7
.changeset/fn-7261-global-modal-outside-dismiss.md
Normal file
7
.changeset/fn-7261-global-modal-outside-dismiss.md
Normal 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.
|
||||||
@@ -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. |
|
| `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). |
|
| `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. |
|
| `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. |
|
| `defaultProvider` | `string` | `undefined` | Default AI provider. |
|
||||||
| `defaultModelId` | `string` | `undefined` | Default AI model ID. |
|
| `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. |
|
| `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. |
|
||||||
|
|||||||
@@ -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", () => {
|
describe("mergeIntegrationWorktree default", () => {
|
||||||
it("defaults project settings to reuse-task-worktree", () => {
|
it("defaults project settings to reuse-task-worktree", () => {
|
||||||
expect(DEFAULT_PROJECT_SETTINGS.mergeIntegrationWorktree).toBe("reuse-task-worktree");
|
expect(DEFAULT_PROJECT_SETTINGS.mergeIntegrationWorktree).toBe("reuse-task-worktree");
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
|||||||
colorTheme: "ocean",
|
colorTheme: "ocean",
|
||||||
shadcnCustomColors: undefined,
|
shadcnCustomColors: undefined,
|
||||||
dashboardFontScalePct: 100,
|
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,
|
language: undefined,
|
||||||
defaultProvider: undefined,
|
defaultProvider: undefined,
|
||||||
defaultModelId: undefined,
|
defaultModelId: undefined,
|
||||||
|
|||||||
@@ -2954,6 +2954,11 @@ export interface GlobalSettings {
|
|||||||
shadcnCustomColors?: Record<string, string>;
|
shadcnCustomColors?: Record<string, string>;
|
||||||
/** Dashboard font size scale percentage. Bounded to 85-125. Default: 100. */
|
/** Dashboard font size scale percentage. Bounded to 85-125. Default: 100. */
|
||||||
dashboardFontScalePct?: number;
|
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`.
|
/** Active UI locale (e.g. `"en"`, `"zh-CN"`, `"fr"`). One of `SUPPORTED_LOCALES`.
|
||||||
* When unset, each surface resolves the locale at runtime (browser/env
|
* When unset, each surface resolves the locale at runtime (browser/env
|
||||||
* detection) and falls back to `DEFAULT_LOCALE` ("en"). */
|
* detection) and falls back to `DEFAULT_LOCALE` ("en"). */
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { ConfirmDialogProvider } from "./hooks/useConfirm";
|
|||||||
import { useTheme } from "./hooks/useTheme";
|
import { useTheme } from "./hooks/useTheme";
|
||||||
import { useModalManager, type DetailTaskOrigin, type DetailTaskTab } from "./hooks/useModalManager";
|
import { useModalManager, type DetailTaskOrigin, type DetailTaskTab } from "./hooks/useModalManager";
|
||||||
import { useAppSettings } from "./hooks/useAppSettings";
|
import { useAppSettings } from "./hooks/useAppSettings";
|
||||||
|
import { ModalDismissPreferenceProvider } from "./hooks/useOverlayDismiss";
|
||||||
import { useDeepLink } from "./hooks/useDeepLink";
|
import { useDeepLink } from "./hooks/useDeepLink";
|
||||||
import { useFavorites } from "./hooks/useFavorites";
|
import { useFavorites } from "./hooks/useFavorites";
|
||||||
import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
|
import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
|
||||||
@@ -560,6 +561,7 @@ function AppInner() {
|
|||||||
openMobileTasksInPopup,
|
openMobileTasksInPopup,
|
||||||
quickChatButtonMode,
|
quickChatButtonMode,
|
||||||
quickChatCloseOnOutsideClick,
|
quickChatCloseOnOutsideClick,
|
||||||
|
dismissModalsOnOutsideClick,
|
||||||
maxTotalRetriesBeforeFail,
|
maxTotalRetriesBeforeFail,
|
||||||
prAuthAvailable,
|
prAuthAvailable,
|
||||||
settingsLoaded,
|
settingsLoaded,
|
||||||
@@ -1372,9 +1374,10 @@ function AppInner() {
|
|||||||
setShowGitHubStarPrompt,
|
setShowGitHubStarPrompt,
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<NavigationHistoryProvider value={{ pushNav, replaceCurrent, removeNav }}>
|
<ModalDismissPreferenceProvider enabled={dismissModalsOnOutsideClick}>
|
||||||
<FileBrowserProvider openFile={openFileInBrowser}>
|
<NavigationHistoryProvider value={{ pushNav, replaceCurrent, removeNav }}>
|
||||||
<RetryWarningProvider value={maxTotalRetriesBeforeFail * RETRY_WARNING_RATIO}>
|
<FileBrowserProvider openFile={openFileInBrowser}>
|
||||||
|
<RetryWarningProvider value={maxTotalRetriesBeforeFail * RETRY_WARNING_RATIO}>
|
||||||
{isFirstEverBoot ? (
|
{isFirstEverBoot ? (
|
||||||
<>
|
<>
|
||||||
<DashboardLoader stage={loadingStage} />
|
<DashboardLoader stage={loadingStage} />
|
||||||
@@ -1702,9 +1705,10 @@ function AppInner() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</RetryWarningProvider>
|
</RetryWarningProvider>
|
||||||
</FileBrowserProvider>
|
</FileBrowserProvider>
|
||||||
</NavigationHistoryProvider>
|
</NavigationHistoryProvider>
|
||||||
|
</ModalDismissPreferenceProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "./AgentErrorDetailsModal.css";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { AlertCircle, Check, Copy, ExternalLink } from "lucide-react";
|
import { AlertCircle, Check, Copy, ExternalLink } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||||
|
|
||||||
const DEFAULT_ISSUE_URL = "https://github.com/Runfusion/Fusion/issues/new";
|
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 [copied, setCopied] = useState(false);
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const issueUrl = useMemo(() => buildAgentErrorIssueUrl(errorText, issueContext), [errorText, issueContext]);
|
const issueUrl = useMemo(() => buildAgentErrorIssueUrl(errorText, issueContext), [errorText, issueContext]);
|
||||||
|
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 agent-error-modal">
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h2 className="modal-title">
|
<h2 className="modal-title">
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { AgentErrorDetailsModal, AgentErrorIndicator } from "../AgentErrorDetailsModal";
|
import { AgentErrorDetailsModal, AgentErrorIndicator } from "../AgentErrorDetailsModal";
|
||||||
import { loadAllAppCss } from "../../test/cssFixture";
|
import { loadAllAppCss } from "../../test/cssFixture";
|
||||||
|
import { ModalDismissPreferenceProvider } from "../../hooks/useOverlayDismiss";
|
||||||
|
|
||||||
const issueContext = {
|
const issueContext = {
|
||||||
surface: "AgentsView",
|
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 () => {
|
it("opens github report link", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AgentErrorDetailsModal open={true} onClose={vi.fn()} errorText="report me" issueContext={issueContext} />);
|
render(<AgentErrorDetailsModal open={true} onClose={vi.fn()} errorText="report me" issueContext={issueContext} />);
|
||||||
|
|||||||
@@ -513,7 +513,9 @@ describe("SettingsModal", () => {
|
|||||||
renderModal({ initialSection: "global-general" });
|
renderModal({ initialSection: "global-general" });
|
||||||
await waitForSettingsModalReady();
|
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.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();
|
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();
|
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 () => {
|
it("saves persistAgentToolOutput only via global settings payload", async () => {
|
||||||
renderModal({ initialSection: "global-general" });
|
renderModal({ initialSection: "global-general" });
|
||||||
await waitForSettingsModalReady();
|
await waitForSettingsModalReady();
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
|
|||||||
"global-general": new Set([
|
"global-general": new Set([
|
||||||
"githubTrackingDefaultRepo",
|
"githubTrackingDefaultRepo",
|
||||||
"language",
|
"language",
|
||||||
|
"dismissModalsOnOutsideClick",
|
||||||
"persistAgentToolOutput",
|
"persistAgentToolOutput",
|
||||||
"persistAgentThinkingLogPermanent",
|
"persistAgentThinkingLogPermanent",
|
||||||
"persistAgentThinkingLogEphemeral",
|
"persistAgentThinkingLogEphemeral",
|
||||||
|
|||||||
@@ -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>
|
<small>{t("settings.globalGeneral.projectsInheritThisValueWhenTheyDoNot", "Projects inherit this value when they do not set a project default tracking repo.")}</small>
|
||||||
</div>
|
</div>
|
||||||
<CliBinaryPanel />
|
<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">
|
<div className="form-group">
|
||||||
<label htmlFor="persistAgentToolOutput" className="checkbox-label">
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, fireEvent } from "@testing-library/react";
|
import { render, fireEvent } from "@testing-library/react";
|
||||||
import { useOverlayDismiss } from "../useOverlayDismiss";
|
import { ModalDismissPreferenceProvider, useOverlayDismiss } from "../useOverlayDismiss";
|
||||||
|
|
||||||
function OverlayHarness({ onClose }: { onClose: () => void }) {
|
function OverlayHarness({ onClose, enabled }: { onClose: () => void; enabled?: boolean }) {
|
||||||
const props = useOverlayDismiss(onClose);
|
const props = useOverlayDismiss(onClose, enabled === undefined ? undefined : { enabled });
|
||||||
return (
|
return (
|
||||||
<div data-testid="overlay" {...props}>
|
<div data-testid="overlay" {...props}>
|
||||||
<div data-testid="modal-content">content</div>
|
<div data-testid="modal-content">content</div>
|
||||||
@@ -12,7 +12,7 @@ function OverlayHarness({ onClose }: { onClose: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("useOverlayDismiss", () => {
|
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 onClose = vi.fn();
|
||||||
const { getByTestId } = render(<OverlayHarness onClose={onClose} />);
|
const { getByTestId } = render(<OverlayHarness onClose={onClose} />);
|
||||||
const overlay = getByTestId("overlay");
|
const overlay = getByTestId("overlay");
|
||||||
@@ -20,12 +20,23 @@ describe("useOverlayDismiss", () => {
|
|||||||
fireEvent.mouseDown(overlay);
|
fireEvent.mouseDown(overlay);
|
||||||
fireEvent.mouseUp(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);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores compatibility mouse sequence immediately after touch", () => {
|
it("ignores compatibility mouse sequence immediately after touch", () => {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const { getByTestId } = render(<OverlayHarness onClose={onClose} />);
|
const { getByTestId } = render(<OverlayHarness onClose={onClose} enabled />);
|
||||||
const overlay = getByTestId("overlay");
|
const overlay = getByTestId("overlay");
|
||||||
|
|
||||||
fireEvent.touchStart(overlay);
|
fireEvent.touchStart(overlay);
|
||||||
@@ -38,7 +49,7 @@ describe("useOverlayDismiss", () => {
|
|||||||
|
|
||||||
it("does not close when mouse starts inside modal and ends on overlay", () => {
|
it("does not close when mouse starts inside modal and ends on overlay", () => {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const { getByTestId } = render(<OverlayHarness onClose={onClose} />);
|
const { getByTestId } = render(<OverlayHarness onClose={onClose} enabled />);
|
||||||
const overlay = getByTestId("overlay");
|
const overlay = getByTestId("overlay");
|
||||||
const modal = getByTestId("modal-content");
|
const modal = getByTestId("modal-content");
|
||||||
|
|
||||||
@@ -47,4 +58,19 @@ describe("useOverlayDismiss", () => {
|
|||||||
|
|
||||||
expect(onClose).toHaveBeenCalledTimes(0);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface UseAppSettingsResult {
|
|||||||
openMobileTasksInPopup: boolean;
|
openMobileTasksInPopup: boolean;
|
||||||
quickChatButtonMode: QuickChatButtonMode;
|
quickChatButtonMode: QuickChatButtonMode;
|
||||||
quickChatCloseOnOutsideClick: boolean;
|
quickChatCloseOnOutsideClick: boolean;
|
||||||
|
dismissModalsOnOutsideClick: boolean;
|
||||||
showQuickChatFAB: boolean;
|
showQuickChatFAB: boolean;
|
||||||
maxTotalRetriesBeforeFail: number;
|
maxTotalRetriesBeforeFail: number;
|
||||||
prAuthAvailable: boolean;
|
prAuthAvailable: boolean;
|
||||||
@@ -65,6 +66,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
|||||||
const [openMobileTasksInPopup, setOpenMobileTasksInPopup] = useState(false);
|
const [openMobileTasksInPopup, setOpenMobileTasksInPopup] = useState(false);
|
||||||
const [quickChatButtonMode, setQuickChatButtonMode] = useState<QuickChatButtonMode>("off");
|
const [quickChatButtonMode, setQuickChatButtonMode] = useState<QuickChatButtonMode>("off");
|
||||||
const [quickChatCloseOnOutsideClick, setQuickChatCloseOnOutsideClick] = useState(true);
|
const [quickChatCloseOnOutsideClick, setQuickChatCloseOnOutsideClick] = useState(true);
|
||||||
|
const [dismissModalsOnOutsideClick, setDismissModalsOnOutsideClick] = useState(false);
|
||||||
const [showQuickChatFAB, setShowQuickChatFAB] = useState(false);
|
const [showQuickChatFAB, setShowQuickChatFAB] = useState(false);
|
||||||
const [maxTotalRetriesBeforeFail, setMaxTotalRetriesBeforeFail] = useState(25);
|
const [maxTotalRetriesBeforeFail, setMaxTotalRetriesBeforeFail] = useState(25);
|
||||||
const [prAuthAvailable, setPrAuthAvailable] = useState(false);
|
const [prAuthAvailable, setPrAuthAvailable] = useState(false);
|
||||||
@@ -116,6 +118,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
|||||||
: "off";
|
: "off";
|
||||||
setQuickChatButtonMode(nextQuickChatButtonMode);
|
setQuickChatButtonMode(nextQuickChatButtonMode);
|
||||||
setQuickChatCloseOnOutsideClick(settings.quickChatCloseOnOutsideClick !== false);
|
setQuickChatCloseOnOutsideClick(settings.quickChatCloseOnOutsideClick !== false);
|
||||||
|
setDismissModalsOnOutsideClick(settings.dismissModalsOnOutsideClick === true);
|
||||||
setShowQuickChatFAB(nextQuickChatButtonMode === "floating");
|
setShowQuickChatFAB(nextQuickChatButtonMode === "floating");
|
||||||
setMaxTotalRetriesBeforeFail(settings.maxTotalRetriesBeforeFail ?? 25);
|
setMaxTotalRetriesBeforeFail(settings.maxTotalRetriesBeforeFail ?? 25);
|
||||||
setCapacityRiskBannerEnabled(settings.capacityRiskBannerEnabled === true);
|
setCapacityRiskBannerEnabled(settings.capacityRiskBannerEnabled === true);
|
||||||
@@ -151,6 +154,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
|||||||
setOpenTasksInRightSidebar(false);
|
setOpenTasksInRightSidebar(false);
|
||||||
setOpenMobileTasksInPopup(false);
|
setOpenMobileTasksInPopup(false);
|
||||||
setQuickChatCloseOnOutsideClick(true);
|
setQuickChatCloseOnOutsideClick(true);
|
||||||
|
setDismissModalsOnOutsideClick(false);
|
||||||
setTodosEnabled(true);
|
setTodosEnabled(true);
|
||||||
setGoalsEnabled(true);
|
setGoalsEnabled(true);
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -254,6 +258,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
|||||||
openMobileTasksInPopup,
|
openMobileTasksInPopup,
|
||||||
quickChatButtonMode,
|
quickChatButtonMode,
|
||||||
quickChatCloseOnOutsideClick,
|
quickChatCloseOnOutsideClick,
|
||||||
|
dismissModalsOnOutsideClick,
|
||||||
showQuickChatFAB,
|
showQuickChatFAB,
|
||||||
maxTotalRetriesBeforeFail,
|
maxTotalRetriesBeforeFail,
|
||||||
prAuthAvailable,
|
prAuthAvailable,
|
||||||
|
|||||||
@@ -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
|
* 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
|
* does NOT need to stopPropagation — mousedown on the modal sets the ref to
|
||||||
* `false`, so the overlay's mouseup handler bails.
|
* `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;
|
onMouseDown: (e: React.MouseEvent) => void;
|
||||||
onMouseUp: (e: React.MouseEvent) => void;
|
onMouseUp: (e: React.MouseEvent) => void;
|
||||||
onTouchStart: () => void;
|
onTouchStart: () => void;
|
||||||
onTouchEnd: () => 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 startedOnOverlayRef = useRef(false);
|
||||||
const lastTouchAtRef = useRef(0);
|
const lastTouchAtRef = useRef(0);
|
||||||
|
|
||||||
@@ -29,6 +51,10 @@ export function useOverlayDismiss(onClose: () => void): {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (!dismissEnabled) {
|
||||||
|
startedOnOverlayRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Android/webview may emit compatibility mouse events right after touchend.
|
// Android/webview may emit compatibility mouse events right after touchend.
|
||||||
// Ignore those so a newly-mounted overlay is not dismissed immediately.
|
// Ignore those so a newly-mounted overlay is not dismissed immediately.
|
||||||
if (Date.now() - lastTouchAtRef.current < 500) {
|
if (Date.now() - lastTouchAtRef.current < 500) {
|
||||||
@@ -36,7 +62,7 @@ export function useOverlayDismiss(onClose: () => void): {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
startedOnOverlayRef.current = e.target === e.currentTarget;
|
startedOnOverlayRef.current = e.target === e.currentTarget;
|
||||||
}, []);
|
}, [dismissEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
@@ -56,11 +82,11 @@ export function useOverlayDismiss(onClose: () => void): {
|
|||||||
|
|
||||||
const onMouseUp = useCallback(
|
const onMouseUp = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
const shouldClose = startedOnOverlayRef.current && e.target === e.currentTarget;
|
const shouldClose = dismissEnabled && startedOnOverlayRef.current && e.target === e.currentTarget;
|
||||||
startedOnOverlayRef.current = false;
|
startedOnOverlayRef.current = false;
|
||||||
if (shouldClose) onClose();
|
if (shouldClose) onClose();
|
||||||
},
|
},
|
||||||
[onClose],
|
[dismissEnabled, onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { onMouseDown, onMouseUp, onTouchStart: markTouch, onTouchEnd: markTouch };
|
return { onMouseDown, onMouseUp, onTouchStart: markTouch, onTouchEnd: markTouch };
|
||||||
|
|||||||
Reference in New Issue
Block a user