FN-8072: add critical-action confirmation skip setting
Add a global operator preference that bypasses centralized critical-action confirmation dialogs. - Add a global-only skipConfirmationDialogs setting and Settings toggle - Return primary/default confirmation results without rendering dialogs when enabled - Route task reset actions through the centralized confirmation seam and add coverage - Document the setting and add a minor changeset Files changed: .changeset/fn-8072-skip-confirmation-dialogs.md | 7 +++++ docs/settings-reference.md | 1 + .../core/src/__tests__/settings-defaults.test.ts | 12 ++++++++- packages/core/src/settings-schema.ts | 5 ++++ packages/core/src/types.ts | 5 ++++ packages/dashboard/app/App.tsx | 11 ++++---- packages/dashboard/app/components/ListView.tsx | 20 +++++++++++---- packages/dashboard/app/components/TaskCard.tsx | 22 +++++++++++----- .../dashboard/app/components/TaskDetailModal.tsx | 26 +++++++++++-------- .../app/components/__tests__/ListView.test.tsx | 13 ++++++++++ .../__tests__/SettingsModal.general.test.tsx | 19 ++++++++++++++ .../app/components/__tests__/TaskCard.test.tsx | 17 ++++++++++++ .../components/__tests__/TaskDetailModal.test.tsx | 25 ++++++++++++++++++ .../app/components/settings/save-split.ts | 1 + .../sections/GlobalGeneralSection.search.ts | 10 ++++++++ .../settings/sections/GlobalGeneralSection.tsx | 10 ++++++++ .../settings-default-descriptions.test.tsx | 1 + .../app/hooks/__tests__/useConfirm.test.ts | 30 ++++++++++++++++++++++ packages/dashboard/app/hooks/useAppSettings.ts | 4 +++ packages/dashboard/app/hooks/useConfirm.ts | 21 ++++++++++++++- packages/i18n/locales/en/app.json | 4 ++- 21 files changed, 235 insertions(+), 29 deletions(-) Fusion-Task-Id: FN-8072 Fusion-Task-Lineage: bab4b8f2-5996-4161-8733-8e03bb6a7024 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8072-skip-confirmation-dialogs.md
Normal file
7
.changeset/fn-8072-skip-confirmation-dialogs.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add a global option to skip confirmation dialogs for critical actions.
|
||||
category: feature
|
||||
dev: New global setting `skipConfirmationDialogs` (default false); when on, `ConfirmDialogProvider` resolves confirm/confirmWithChoice/confirmWithCheckbox to the primary/default choice without rendering the dialog. Toggle in Settings → Global → General. Reset-task guards in TaskCard/TaskDetailModal/ListView migrated to the useConfirm seam.
|
||||
@@ -57,6 +57,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
||||
| `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. |
|
||||
| `skipConfirmationDialogs` | `boolean` | `false` | Global-only operator preference that skips centralized confirmation dialogs for critical actions. When enabled, destructive actions such as deleting a task or resetting progress immediately take the dialog's primary/default action; project settings cannot enable it for shared-project collaborators. |
|
||||
| `defaultProvider` | `string` | `undefined` | Default AI provider. Anthropic has three independent surfaces, all executing on the direct `anthropic` provider except the CLI: (1) **direct OAuth** — a Claude subscription/OAuth login drives `anthropic/*` selections; Fusion sends the OAuth token to `https://api.anthropic.com/v1` with Claude Code identity headers (the same path the Claude Code CLI uses), so a subscription needs no API key. Credentials live under the `anthropic-subscription` auth/status/usage/banner id but are resolved for the direct provider at runtime; they are never stored or resolved as raw `ANTHROPIC_API_KEY` material. (2) **raw API key** — `ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential uses `x-api-key` on the same direct provider and takes precedence over OAuth. (3) **Claude CLI** — the explicit `pi-claude-cli` model provider runs sessions through the local `claude` CLI. There is no runtime rerouting between these surfaces. |
|
||||
| `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. |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_MAX_AUTO_MERGE_RETRIES, resolveMaxAutoMergeRetries } from "../in-review-stall.js";
|
||||
import { isExperimentalFeatureEnabled } from "../experimental-features.js";
|
||||
import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "../settings-schema.js";
|
||||
import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalOnlySettingsKey } from "../settings-schema.js";
|
||||
import { isWorkflowColumnsEnabled } from "../workflow-columns-settings.js";
|
||||
import {
|
||||
__resetLegacyCwdMainWarningForTests,
|
||||
@@ -238,6 +238,16 @@ describe("settings defaults invariants", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("skipConfirmationDialogs default", () => {
|
||||
it("defaults critical-action confirmation skipping off and global-scoped only", () => {
|
||||
expect(DEFAULT_GLOBAL_SETTINGS.skipConfirmationDialogs).toBe(false);
|
||||
expect(GLOBAL_SETTINGS_KEYS).toContain("skipConfirmationDialogs");
|
||||
expect("skipConfirmationDialogs" in DEFAULT_PROJECT_SETTINGS).toBe(false);
|
||||
expect(PROJECT_SETTINGS_KEYS).not.toContain("skipConfirmationDialogs");
|
||||
expect(isGlobalOnlySettingsKey("skipConfirmationDialogs")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeIntegrationWorktree default", () => {
|
||||
it("defaults project settings to reuse-task-worktree", () => {
|
||||
expect(DEFAULT_PROJECT_SETTINGS.mergeIntegrationWorktree).toBe("reuse-task-worktree");
|
||||
|
||||
@@ -97,6 +97,11 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
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,
|
||||
/*
|
||||
FNXC:Settings 2026-07-16-05:30:
|
||||
Critical-action confirmation dialogs stay enabled by default. This global-only preference may opt an operator into primary/default auto-approval, but project settings cannot enable it for collaborators.
|
||||
*/
|
||||
skipConfirmationDialogs: false,
|
||||
language: undefined,
|
||||
defaultProvider: undefined,
|
||||
defaultModelId: undefined,
|
||||
|
||||
@@ -2367,6 +2367,11 @@ export interface GlobalSettings {
|
||||
* 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;
|
||||
/**
|
||||
* FNXC:Settings 2026-07-16-05:30:
|
||||
* This global operator preference defaults to false. When enabled, the dashboard skips centralized critical-action confirmations and proceeds with their primary/default choice. It must never be project-scoped so shared projects cannot force destructive actions without a prompt.
|
||||
*/
|
||||
skipConfirmationDialogs?: 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"). */
|
||||
|
||||
@@ -688,6 +688,7 @@ function AppInner() {
|
||||
quickChatCloseOnOutsideClick,
|
||||
dashboardKeyboardShortcuts,
|
||||
dismissModalsOnOutsideClick,
|
||||
skipConfirmationDialogs,
|
||||
maxTotalRetriesBeforeFail,
|
||||
prAuthAvailable,
|
||||
settingsLoaded,
|
||||
@@ -1606,7 +1607,8 @@ function AppInner() {
|
||||
setShowGitHubStarPrompt,
|
||||
};
|
||||
return (
|
||||
<ModalDismissPreferenceProvider enabled={dismissModalsOnOutsideClick}>
|
||||
<ConfirmDialogProvider skipConfirmations={skipConfirmationDialogs}>
|
||||
<ModalDismissPreferenceProvider enabled={dismissModalsOnOutsideClick}>
|
||||
<NavigationHistoryProvider value={{ pushNav, replaceCurrent, removeNav }}>
|
||||
<FileBrowserProvider openFile={openFileInBrowser}>
|
||||
<RetryWarningProvider value={maxTotalRetriesBeforeFail * RETRY_WARNING_RATIO}>
|
||||
@@ -1974,7 +1976,8 @@ function AppInner() {
|
||||
</RetryWarningProvider>
|
||||
</FileBrowserProvider>
|
||||
</NavigationHistoryProvider>
|
||||
</ModalDismissPreferenceProvider>
|
||||
</ModalDismissPreferenceProvider>
|
||||
</ConfirmDialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1985,9 +1988,7 @@ export function App() {
|
||||
<ShellHostProvider>
|
||||
<ShellProvider>
|
||||
<NodeProvider>
|
||||
<ConfirmDialogProvider>
|
||||
<AppInner />
|
||||
</ConfirmDialogProvider>
|
||||
<AppInner />
|
||||
</NodeProvider>
|
||||
</ShellProvider>
|
||||
</ShellHostProvider>
|
||||
|
||||
@@ -1767,11 +1767,21 @@ export function ListView({
|
||||
addToast(t("tasks.retryFailed", "Failed to retry {{taskId}}: {{error}}", { taskId: task.id, error: getErrorMessage(err) }), "error");
|
||||
}
|
||||
} : undefined,
|
||||
onReset: onResetTask ? () => {
|
||||
if (!window.confirm(t("taskDetail.reset.confirmMessage", "This will erase all progress for {{id}} and start the task from scratch. Continue?", { id: task.id }))) return;
|
||||
void onResetTask(task.id)
|
||||
.then(() => addToast(t("taskDetail.reset.resetSuccess", "Reset {{id}} — fresh run will be allocated", { id: task.id }), "success"))
|
||||
.catch((err) => addToast(getErrorMessage(err), "error"));
|
||||
onReset: onResetTask ? async () => {
|
||||
const shouldReset = await confirm({
|
||||
title: t("taskDetail.reset.btn", "Reset"),
|
||||
message: t("taskDetail.reset.confirmMessage", "This will erase all progress for {{id}} and start the task from scratch. Continue?", { id: task.id }),
|
||||
confirmLabel: t("taskDetail.reset.btn", "Reset"),
|
||||
cancelLabel: t("common.cancel", "Cancel"),
|
||||
danger: true,
|
||||
});
|
||||
if (!shouldReset) return;
|
||||
try {
|
||||
await onResetTask(task.id);
|
||||
addToast(t("taskDetail.reset.resetSuccess", "Reset {{id}} — fresh run will be allocated", { id: task.id }), "success");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
} : undefined,
|
||||
onTogglePause: (isTaskPaused ? onUnpauseTask : onPauseTask) ? async () => {
|
||||
try {
|
||||
|
||||
@@ -2187,13 +2187,23 @@ function TaskCardComponent({
|
||||
}
|
||||
}, [addToast, isPaused, onPauseTask, onUnpauseTask, task.id, t]);
|
||||
|
||||
const handleTaskActionReset = useCallback(() => {
|
||||
const handleTaskActionReset = useCallback(async () => {
|
||||
if (!onResetTask) return;
|
||||
if (!window.confirm(t("taskDetail.reset.confirmMessage", "This will erase all progress for {{id}} and start the task from scratch. Continue?", { id: task.id }))) return;
|
||||
void onResetTask(task.id)
|
||||
.then(() => addToast(t("taskDetail.reset.resetSuccess", "Reset {{id}} — fresh run will be allocated", { id: task.id }), "success"))
|
||||
.catch((err) => addToast(getErrorMessage(err), "error"));
|
||||
}, [addToast, onResetTask, task.id, t]);
|
||||
const shouldReset = await confirm({
|
||||
title: t("taskDetail.reset.btn", "Reset"),
|
||||
message: t("taskDetail.reset.confirmMessage", "This will erase all progress for {{id}} and start the task from scratch. Continue?", { id: task.id }),
|
||||
confirmLabel: t("taskDetail.reset.btn", "Reset"),
|
||||
cancelLabel: t("common.cancel", "Cancel"),
|
||||
danger: true,
|
||||
});
|
||||
if (!shouldReset) return;
|
||||
try {
|
||||
await onResetTask(task.id);
|
||||
addToast(t("taskDetail.reset.resetSuccess", "Reset {{id}} — fresh run will be allocated", { id: task.id }), "success");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
}, [addToast, confirm, onResetTask, task.id, t]);
|
||||
|
||||
const handleTaskActionDuplicate = useCallback(async () => {
|
||||
if (!onDuplicateTask) return;
|
||||
|
||||
@@ -2668,18 +2668,24 @@ export function TaskDetailContent({
|
||||
});
|
||||
}, [task.id, onBypassReview, onTaskUpdated, addToast, t]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
const handleReset = useCallback(async () => {
|
||||
if (!onResetTask) return;
|
||||
if (!window.confirm(t("taskDetail.reset.confirmMessage", "This will erase all progress for {{id}} and start the task from scratch. Continue?", { id: task.id }))) return;
|
||||
const shouldReset = await confirm({
|
||||
title: t("taskDetail.reset.btn", "Reset"),
|
||||
message: t("taskDetail.reset.confirmMessage", "This will erase all progress for {{id}} and start the task from scratch. Continue?", { id: task.id }),
|
||||
confirmLabel: t("taskDetail.reset.btn", "Reset"),
|
||||
cancelLabel: t("common.cancel", "Cancel"),
|
||||
danger: true,
|
||||
});
|
||||
if (!shouldReset) return;
|
||||
requestClose();
|
||||
onResetTask(task.id)
|
||||
.then(() => {
|
||||
addToast(t("taskDetail.reset.resetSuccess", "Reset {{id}} — fresh run will be allocated", { id: task.id }), "success");
|
||||
})
|
||||
.catch((err) => {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
});
|
||||
}, [task.id, onResetTask, requestClose, addToast]);
|
||||
try {
|
||||
await onResetTask(task.id);
|
||||
addToast(t("taskDetail.reset.resetSuccess", "Reset {{id}} — fresh run will be allocated", { id: task.id }), "success");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
}, [task.id, onResetTask, requestClose, addToast, confirm, t]);
|
||||
|
||||
const handleDuplicate = useCallback(async () => {
|
||||
if (!onDuplicateTask) return;
|
||||
|
||||
@@ -839,6 +839,19 @@ describe("ListView", () => {
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("routes reset through the centralized confirm seam and proceeds in skip mode", async () => {
|
||||
const onResetTask = vi.fn(async () => createMockTask());
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
renderListView({ tasks: [createMockTask({ id: "FN-901", column: "in-progress" })], onResetTask });
|
||||
|
||||
fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-901"]') as HTMLElement, { clientX: 40, clientY: 50 });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Reset" }));
|
||||
|
||||
await waitFor(() => expect(onResetTask).toHaveBeenCalledWith("FN-901"));
|
||||
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true, title: "Reset" }));
|
||||
expect(document.querySelector(".confirm-dialog-overlay")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens Planning Mode from eligible list row menus and omits it for executing rows", async () => {
|
||||
const viewportSpy = mockDesktopViewport();
|
||||
const onPlanningMode = vi.fn();
|
||||
|
||||
@@ -842,6 +842,25 @@ describe("SettingsModal", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("saves skipConfirmationDialogs only via global settings payload", async () => {
|
||||
renderModal({ initialSection: "global-general" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await settingsModalUser.click(screen.getByRole("checkbox", { name: "Skip confirmation dialogs for critical actions" }));
|
||||
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.skipConfirmationDialogs).toBe(true);
|
||||
if (mockUpdateSettings.mock.calls.length > 0) {
|
||||
const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(projectPayload.skipConfirmationDialogs).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("saves persistAgentToolOutput only via global settings payload", async () => {
|
||||
renderModal({ initialSection: "global-general" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
@@ -456,6 +456,23 @@ describe("TaskCard", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("routes reset through the centralized confirm seam and proceeds in skip mode", async () => {
|
||||
const cleanupGeometry = mockBoardContextMenuGeometry();
|
||||
const onResetTask = vi.fn(async () => makeTask());
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
try {
|
||||
render(<TaskCard task={makeTask({ column: "in-progress" })} onOpenDetail={noop} onResetTask={onResetTask} addToast={noop} />);
|
||||
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
|
||||
await waitFor(() => expectBoardContextMenuPortaled());
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Reset" }));
|
||||
await waitFor(() => expect(onResetTask).toHaveBeenCalledWith("FN-001"));
|
||||
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true, title: "Reset" }));
|
||||
expect(document.querySelector(".confirm-dialog-overlay")).toBeNull();
|
||||
} finally {
|
||||
cleanupGeometry();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:TaskCardMenu 2026-07-10-12:00:
|
||||
The card actions menu must ALSO be reachable from the visible ⋯ button (first-run users never
|
||||
|
||||
@@ -101,6 +101,31 @@ function createDeferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("TaskDetailModal reset confirmations", () => {
|
||||
it("routes reset through the centralized confirm seam and proceeds in skip mode", async () => {
|
||||
const onResetTask = vi.fn(async () => makeTask());
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-001", column: "in-progress" as any })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onResetTask={onResetTask}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Actions" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Reset" }));
|
||||
await waitFor(() => expect(onResetTask).toHaveBeenCalledWith("FN-001"));
|
||||
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true, title: "Reset" }));
|
||||
expect(document.querySelector(".confirm-dialog-overlay")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskDetailModal planner Chat tab", () => {
|
||||
function renderTask(column: any = "in-progress", initialTab?: ComponentProps<typeof TaskDetailModal>["initialTab"]) {
|
||||
return render(
|
||||
|
||||
@@ -138,6 +138,7 @@ export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
|
||||
"global-general": new Set([
|
||||
"language",
|
||||
"dismissModalsOnOutsideClick",
|
||||
"skipConfirmationDialogs",
|
||||
"persistAgentToolOutput",
|
||||
"persistAgentThinkingLogPermanent",
|
||||
"persistAgentThinkingLogEphemeral",
|
||||
|
||||
@@ -21,6 +21,16 @@ export const globalGeneralSearchEntries: SettingsSearchEntry[] = [
|
||||
" When enabled, clicking or tapping a modal backdrop closes the modal. Default: disabled, to prevent accidental dismissal. ",
|
||||
keywords: ["dialog", "backdrop", "accidental close"],
|
||||
},
|
||||
{
|
||||
sectionId: "global-general",
|
||||
key: "skipConfirmationDialogs",
|
||||
labelKey: "settings.globalGeneral.skipConfirmationDialogs",
|
||||
labelFallback: " Skip confirmation dialogs for critical actions ",
|
||||
helpKey: "settings.globalGeneral.skipConfirmationDialogsHint",
|
||||
helpFallback:
|
||||
" When enabled, destructive actions such as deleting a task or resetting progress run immediately without a prompt. Default: disabled",
|
||||
keywords: ["confirm", "critical action", "delete", "reset", "destructive"],
|
||||
},
|
||||
{
|
||||
sectionId: "global-general",
|
||||
key: "persistAgentToolOutput",
|
||||
|
||||
@@ -36,6 +36,16 @@ export function GlobalGeneralSection({ form, setForm }: GlobalGeneralSectionProp
|
||||
value={form.dismissModalsOnOutsideClick === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, dismissModalsOnOutsideClick: v === true }))}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "skipConfirmationDialogs",
|
||||
label: t("settings.globalGeneral.skipConfirmationDialogs", " Skip confirmation dialogs for critical actions "),
|
||||
help: t("settings.globalGeneral.skipConfirmationDialogsHint", " When enabled, destructive actions such as deleting a task or resetting progress run immediately without a prompt. Default: disabled"),
|
||||
scope: "global",
|
||||
}}
|
||||
value={form.skipConfirmationDialogs === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, skipConfirmationDialogs: v === true }))}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "persistAgentToolOutput",
|
||||
|
||||
@@ -75,6 +75,7 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
|
||||
gitlabAuthTokenType: "globalGeneral.gitLabTokenTypeHint",
|
||||
gitlabAuthToken: "globalGeneral.gitLabAuthTokenHint",
|
||||
dismissModalsOnOutsideClick: "globalGeneral.dismissModalsByClickingOutsideHint",
|
||||
skipConfirmationDialogs: "globalGeneral.skipConfirmationDialogsHint",
|
||||
persistAgentToolOutput: "globalGeneral.whenDisabledToolRowsAreStillLoggedBut",
|
||||
persistAgentThinkingLogPermanent: "globalGeneral.rowsAndDoesNotAffectAssistantTextOr",
|
||||
persistAgentThinkingLogEphemeral: "globalGeneral.rowsAndDoesNotAffectAssistantTextOr",
|
||||
|
||||
@@ -58,6 +58,16 @@ function Harness() {
|
||||
},
|
||||
"open-checkbox-default-checked"
|
||||
),
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
onClick: async () => {
|
||||
const outcome = await confirmWithCheckbox({ title: "Reset Task", message: "Reset FN-001?" });
|
||||
setResult(JSON.stringify(outcome));
|
||||
},
|
||||
},
|
||||
"open-checkbox-without-checkbox"
|
||||
),
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
@@ -78,6 +88,26 @@ function Harness() {
|
||||
}
|
||||
|
||||
describe("useConfirm", () => {
|
||||
it("skips dialogs with the primary/default outcomes when enabled", async () => {
|
||||
render(React.createElement(ConfirmDialogProvider, { skipConfirmations: true }, React.createElement(Harness)));
|
||||
|
||||
fireEvent.click(screen.getByText("open"));
|
||||
await waitFor(() => expect(screen.getByTestId("result")).toHaveTextContent("confirmed"));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("open-choice"));
|
||||
await waitFor(() => expect(screen.getByTestId("result")).toHaveTextContent("primary"));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("open-checkbox-default-checked"));
|
||||
await waitFor(() => expect(screen.getByTestId("result")).toHaveTextContent('{"choice":"primary","checkboxValue":true}'));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("open-checkbox-without-checkbox"));
|
||||
await waitFor(() => expect(screen.getByTestId("result")).toHaveTextContent('{"choice":"primary","checkboxValue":false}'));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resolves true when confirm is clicked", async () => {
|
||||
render(
|
||||
React.createElement(
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface UseAppSettingsResult {
|
||||
quickChatCloseOnOutsideClick: boolean;
|
||||
dashboardKeyboardShortcuts: Required<DashboardKeyboardShortcutMap>;
|
||||
dismissModalsOnOutsideClick: boolean;
|
||||
skipConfirmationDialogs: boolean;
|
||||
showQuickChatFAB: boolean;
|
||||
maxTotalRetriesBeforeFail: number;
|
||||
prAuthAvailable: boolean;
|
||||
@@ -95,6 +96,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
const [quickChatCloseOnOutsideClick, setQuickChatCloseOnOutsideClick] = useState(true);
|
||||
const [dashboardKeyboardShortcuts, setDashboardKeyboardShortcuts] = useState<Required<DashboardKeyboardShortcutMap>>(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS);
|
||||
const [dismissModalsOnOutsideClick, setDismissModalsOnOutsideClick] = useState(false);
|
||||
const [skipConfirmationDialogs, setSkipConfirmationDialogs] = useState(false);
|
||||
const [showQuickChatFAB, setShowQuickChatFAB] = useState(false);
|
||||
const [maxTotalRetriesBeforeFail, setMaxTotalRetriesBeforeFail] = useState(25);
|
||||
const [prAuthAvailable, setPrAuthAvailable] = useState(false);
|
||||
@@ -162,6 +164,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
setQuickChatCloseOnOutsideClick(settings.quickChatCloseOnOutsideClick !== false);
|
||||
setDashboardKeyboardShortcuts(resolveDashboardKeyboardShortcuts((settings as GlobalSettings).dashboardKeyboardShortcuts));
|
||||
setDismissModalsOnOutsideClick(settings.dismissModalsOnOutsideClick === true);
|
||||
setSkipConfirmationDialogs(settings.skipConfirmationDialogs === true);
|
||||
setShowQuickChatFAB(nextQuickChatButtonMode === "floating");
|
||||
setMaxTotalRetriesBeforeFail(settings.maxTotalRetriesBeforeFail ?? 25);
|
||||
setCapacityRiskBannerEnabled(settings.capacityRiskBannerEnabled === true);
|
||||
@@ -349,6 +352,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
quickChatCloseOnOutsideClick,
|
||||
dashboardKeyboardShortcuts,
|
||||
dismissModalsOnOutsideClick,
|
||||
skipConfirmationDialogs,
|
||||
showQuickChatFAB,
|
||||
maxTotalRetriesBeforeFail,
|
||||
prAuthAvailable,
|
||||
|
||||
@@ -34,9 +34,17 @@ interface ConfirmContextValue {
|
||||
|
||||
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
|
||||
|
||||
export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
|
||||
export function ConfirmDialogProvider({
|
||||
children,
|
||||
skipConfirmations = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
skipConfirmations?: boolean;
|
||||
}) {
|
||||
const [queue, setQueue] = useState<PendingConfirm[]>([]);
|
||||
const queueRef = useRef<PendingConfirm[]>([]);
|
||||
const skipConfirmationsRef = useRef(skipConfirmations);
|
||||
skipConfirmationsRef.current = skipConfirmations;
|
||||
|
||||
const updateQueue = useCallback((updater: (current: PendingConfirm[]) => PendingConfirm[]) => {
|
||||
setQueue((current) => {
|
||||
@@ -47,6 +55,17 @@ export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const confirmWithCheckbox = useCallback((options: ConfirmOptions) => {
|
||||
/*
|
||||
FNXC:ConfirmDialogs 2026-07-16-05:30:
|
||||
Operators who globally skip critical-action confirmations must receive the same primary/default result as clicking the dialog's primary button. Never invent a different outcome or enqueue a hidden dialog; checkbox prompts retain their configured default value.
|
||||
*/
|
||||
if (skipConfirmationsRef.current) {
|
||||
return Promise.resolve({
|
||||
choice: "primary" as const,
|
||||
checkboxValue: options.checkbox?.defaultChecked ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise<{ choice: ConfirmChoice; checkboxValue: boolean }>((resolve) => {
|
||||
updateQueue((current) => [
|
||||
...current,
|
||||
|
||||
@@ -5969,7 +5969,9 @@
|
||||
"gitLabApiBaseUrlHint": "Blank derives <instance>/api/v4. Override only for self-managed GitLab API gateways that use a different absolute http:// or https:// URL. No default — unset.",
|
||||
"gitLabTokenTypeHint": "No default — unset (the selector falls back to personal access token until you choose otherwise).",
|
||||
"gitLabAuthTokenHint": "Projects inherit this fallback only when they do not set a project GitLab token. Read-only operations need read_api or api; write actions need api; project/group tokens remain limited by resource membership. No default — unset.",
|
||||
"dismissModalsByClickingOutsideHint": " When enabled, clicking or tapping a modal backdrop closes the modal. Default: disabled, to prevent accidental dismissal. "
|
||||
"dismissModalsByClickingOutsideHint": " When enabled, clicking or tapping a modal backdrop closes the modal. Default: disabled, to prevent accidental dismissal. ",
|
||||
"skipConfirmationDialogs": " Skip confirmation dialogs for critical actions ",
|
||||
"skipConfirmationDialogsHint": " When enabled, destructive actions such as deleting a task or resetting progress run immediately without a prompt. Default: disabled"
|
||||
},
|
||||
"globalModels": {
|
||||
"allow": "allow",
|
||||
|
||||
Reference in New Issue
Block a user