diff --git a/.changeset/fn-8072-skip-confirmation-dialogs.md b/.changeset/fn-8072-skip-confirmation-dialogs.md new file mode 100644 index 0000000000..3b4410fc4f --- /dev/null +++ b/.changeset/fn-8072-skip-confirmation-dialogs.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 712555047a..77e9276809 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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` | `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. | diff --git a/packages/core/src/__tests__/settings-defaults.test.ts b/packages/core/src/__tests__/settings-defaults.test.ts index 41a84e1450..d539854931 100644 --- a/packages/core/src/__tests__/settings-defaults.test.ts +++ b/packages/core/src/__tests__/settings-defaults.test.ts @@ -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"); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index afbf4e16ef..aa8c2eabd4 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -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, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7365c64d05..b7287f59a1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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"). */ diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 1ef38e29c8..789650b037 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -688,6 +688,7 @@ function AppInner() { quickChatCloseOnOutsideClick, dashboardKeyboardShortcuts, dismissModalsOnOutsideClick, + skipConfirmationDialogs, maxTotalRetriesBeforeFail, prAuthAvailable, settingsLoaded, @@ -1606,7 +1607,8 @@ function AppInner() { setShowGitHubStarPrompt, }; return ( - + + @@ -1974,7 +1976,8 @@ function AppInner() { - + + ); } @@ -1985,9 +1988,7 @@ export function App() { - - - + diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index f5b041fe2d..2a4f5d6104 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -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 { diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 71e50a0367..f6c2d58231 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -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; diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index c65d12a228..ea59a64531 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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; diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 1f54c0d7da..d4a2d31938 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -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(); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index a7a5606289..a5d984851d 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -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; + expect(globalPayload.skipConfirmationDialogs).toBe(true); + if (mockUpdateSettings.mock.calls.length > 0) { + const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record; + expect(projectPayload.skipConfirmationDialogs).toBeUndefined(); + } + }); + it("saves persistAgentToolOutput only via global settings payload", async () => { renderModal({ initialSection: "global-general" }); await waitForSettingsModalReady(); diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index b9612c9f29..3a4cfeada0 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -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(); + 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 diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx index e6215be123..02d7efc9c7 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx @@ -101,6 +101,31 @@ function createDeferred() { 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( + , + ); + + 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["initialTab"]) { return render( diff --git a/packages/dashboard/app/components/settings/save-split.ts b/packages/dashboard/app/components/settings/save-split.ts index f6d333944a..2237b51943 100644 --- a/packages/dashboard/app/components/settings/save-split.ts +++ b/packages/dashboard/app/components/settings/save-split.ts @@ -138,6 +138,7 @@ export const GLOBAL_SECTION_KEYS: Record> = { "global-general": new Set([ "language", "dismissModalsOnOutsideClick", + "skipConfirmationDialogs", "persistAgentToolOutput", "persistAgentThinkingLogPermanent", "persistAgentThinkingLogEphemeral", diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.search.ts b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.search.ts index 9b8229c3d5..013c927463 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.search.ts +++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.search.ts @@ -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", diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx index 9984655a45..2a558ce08e 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx @@ -36,6 +36,16 @@ export function GlobalGeneralSection({ form, setForm }: GlobalGeneralSectionProp value={form.dismissModalsOnOutsideClick === true} onChange={(v) => setForm((f) => ({ ...f, dismissModalsOnOutsideClick: v === true }))} /> + setForm((f) => ({ ...f, skipConfirmationDialogs: v === true }))} + /> = { gitlabAuthTokenType: "globalGeneral.gitLabTokenTypeHint", gitlabAuthToken: "globalGeneral.gitLabAuthTokenHint", dismissModalsOnOutsideClick: "globalGeneral.dismissModalsByClickingOutsideHint", + skipConfirmationDialogs: "globalGeneral.skipConfirmationDialogsHint", persistAgentToolOutput: "globalGeneral.whenDisabledToolRowsAreStillLoggedBut", persistAgentThinkingLogPermanent: "globalGeneral.rowsAndDoesNotAffectAssistantTextOr", persistAgentThinkingLogEphemeral: "globalGeneral.rowsAndDoesNotAffectAssistantTextOr", diff --git a/packages/dashboard/app/hooks/__tests__/useConfirm.test.ts b/packages/dashboard/app/hooks/__tests__/useConfirm.test.ts index 52065bfc50..441f9aea84 100644 --- a/packages/dashboard/app/hooks/__tests__/useConfirm.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useConfirm.test.ts @@ -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( diff --git a/packages/dashboard/app/hooks/useAppSettings.ts b/packages/dashboard/app/hooks/useAppSettings.ts index 3af4478bfd..cd6f4fb4db 100644 --- a/packages/dashboard/app/hooks/useAppSettings.ts +++ b/packages/dashboard/app/hooks/useAppSettings.ts @@ -37,6 +37,7 @@ export interface UseAppSettingsResult { quickChatCloseOnOutsideClick: boolean; dashboardKeyboardShortcuts: Required; 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>(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, diff --git a/packages/dashboard/app/hooks/useConfirm.ts b/packages/dashboard/app/hooks/useConfirm.ts index 10214e4fa8..5faba7f5c2 100644 --- a/packages/dashboard/app/hooks/useConfirm.ts +++ b/packages/dashboard/app/hooks/useConfirm.ts @@ -34,9 +34,17 @@ interface ConfirmContextValue { const ConfirmContext = createContext(null); -export function ConfirmDialogProvider({ children }: { children: ReactNode }) { +export function ConfirmDialogProvider({ + children, + skipConfirmations = false, +}: { + children: ReactNode; + skipConfirmations?: boolean; +}) { const [queue, setQueue] = useState([]); const queueRef = useRef([]); + 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, diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 8aff3ce751..7e40dd6db3 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -5969,7 +5969,9 @@ "gitLabApiBaseUrlHint": "Blank derives /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",