From ffe209210937a95093e088fe9bbe8be8d66ad444 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 29 Jun 2026 19:29:36 -0700 Subject: [PATCH] FN-7248: require confirmation for footer concurrency edits Footer concurrency controls now require explicit confirmation before persisting scheduler-capacity changes. - Add local pending state for global and project footer concurrency slider edits so dismissals revert instead of saving. - Reuse Command Center confirmation copy for single and grouped concurrency changes. - Cover confirm, cancel, close, Escape, outside-click, loading, and error behaviors in EngineControlMenu tests. - Document the footer confirmation and dismissal semantics and add a patch changeset. Files changed: .../fn-7248-footer-concurrency-confirmation.md | 7 + docs/dashboard-guide.md | 3 +- .../dashboard/app/components/EngineControlMenu.tsx | 235 ++++++++++++++--- .../__tests__/EngineControlMenu.test.tsx | 287 +++++++++++++++++++-- 4 files changed, 478 insertions(+), 54 deletions(-) Fusion-Task-Id: FN-7248 Fusion-Task-Lineage: cbcd21fa-62c3-4c05-ac3d-67a641cf47c8 Co-authored-by: Fusion (runfusion.ai) --- ...fn-7248-footer-concurrency-confirmation.md | 7 + docs/dashboard-guide.md | 3 +- .../app/components/EngineControlMenu.tsx | 235 +++++++++++--- .../__tests__/EngineControlMenu.test.tsx | 287 +++++++++++++++++- 4 files changed, 478 insertions(+), 54 deletions(-) create mode 100644 .changeset/fn-7248-footer-concurrency-confirmation.md diff --git a/.changeset/fn-7248-footer-concurrency-confirmation.md b/.changeset/fn-7248-footer-concurrency-confirmation.md new file mode 100644 index 0000000000..afbe4a88a5 --- /dev/null +++ b/.changeset/fn-7248-footer-concurrency-confirmation.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Footer concurrency controls now ask before saving capacity changes. +category: fix +dev: Mirrors Command Center confirmation semantics in EngineControlMenu so global and per-project concurrency edits persist only after explicit confirmation. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index f33506c172..59e76c6f5b 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1039,7 +1039,8 @@ Use this panel when upgrading a project with pre-FN-6245/FN-6277 in-review rows ### Executor footer engine controls -The global AI engine stop/start control and triage pause/resume control live in the executor footer status bar rather than the header. Select the small engine-controls button beside the executor state badge, or select the state text such as **Running**, to open the footer popover. The popover includes **Stop AI engine** / **Start AI engine**, **Pause triage** / **Resume scheduling**, and live scheduler sliders for max concurrent tasks, max triage concurrency, and max worktrees. Use the visible **Close engine controls** X button, Escape, or outside-click to dismiss it. The global and current-project concurrency sliders also show how many agents are running, including actively-triaging planners (`triage` + `planning`, not paused), and a dot on the slider track for current use. The dot uses absolute utilization (`running / cap`) rather than range-slider coordinates, so one running agent renders above the start of the track, zero stays at the start, and over-cap usage clamps to the end. Slider changes save through the existing `/api/settings` path with the same debounced behavior used by Command Center controls; no separate backend route is required. + +The global AI engine stop/start control and triage pause/resume control live in the executor footer status bar rather than the header. Select the small engine-controls button beside the executor state badge, or select the state text such as **Running**, to open the footer popover. The popover includes **Stop AI engine** / **Start AI engine**, **Pause triage** / **Resume scheduling**, and live scheduler sliders for max concurrent tasks, max triage concurrency, and max worktrees. Use the visible **Close engine controls** X button, Escape, or outside-click to dismiss it. The global and current-project concurrency sliders also show how many agents are running, including actively-triaging planners (`triage` + `planning`, not paused), and a dot on the slider track for current use. The dot uses absolute utilization (`running / cap`) rather than range-slider coordinates, so one running agent renders above the start of the track, zero stays at the start, and over-cap usage clamps to the end. Changed concurrency slider values ask for confirmation after the value settles. Confirming saves the global cap through `/api/global-concurrency` and project caps through `/api/settings`; cancel, backdrop dismissal, Escape, close, outside-click, or unmount reverts unconfirmed slider edits without saving. Multiple changed project sliders within one debounce window are summarized in one confirmation dialog, matching Command Center behavior. Brief, single-poll executor stats fetch blips keep showing the last good footer stats instead of flashing **Connecting…**. Routine executor stats heartbeats also keep the populated footer mounted after initial load, so an open engine/concurrency popover stays open while counts refresh. The footer only switches to **Connecting…** for sustained suspension-like stats failures, or to an explicit error state for non-transient failures. diff --git a/packages/dashboard/app/components/EngineControlMenu.tsx b/packages/dashboard/app/components/EngineControlMenu.tsx index b960be6eb6..e9f39dbfbc 100644 --- a/packages/dashboard/app/components/EngineControlMenu.tsx +++ b/packages/dashboard/app/components/EngineControlMenu.tsx @@ -5,6 +5,7 @@ import { DEFAULT_PROJECT_SETTINGS } from "@fusion/core"; import { Pause, Play, SlidersHorizontal, Square, X } from "lucide-react"; import { fetchConfig, fetchSettings, updateSettings } from "../api/legacy"; import { useAppSettings } from "../hooks/useAppSettings"; +import { useConfirm } from "../hooks/useConfirm"; // FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Footer menu adopts the shared global-concurrency hook so it and the Command Center card read/write ONE source of truth (no more duplicated fetch/debounce/clobber logic). import { useGlobalConcurrency } from "../hooks/useGlobalConcurrency"; @@ -42,6 +43,12 @@ const CONCURRENCY_SLIDER_LIMITS: Record = { + maxConcurrent: { key: "commandCenter.controls.concurrency.maxConcurrent", defaultValue: "Max concurrent tasks" }, + maxTriageConcurrent: { key: "commandCenter.controls.concurrency.maxTriageConcurrent", defaultValue: "Max triage concurrent" }, + maxWorktrees: { key: "commandCenter.controls.concurrency.maxWorktrees", defaultValue: "Max worktrees" }, +}; + function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } @@ -54,6 +61,22 @@ function getErrorMessage(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; } +function getChangedConcurrencyKeys(values: ConcurrencyValues, persisted: ConcurrencyValues) { + return (Object.keys(values) as Array).filter((key) => values[key] !== persisted[key]); +} + +/* +FNXC:EngineControls 2026-06-29-16:15: +Footer confirmation copy must stay aligned with the Command Center concurrency card. Build single-setting messages from the shared summary item key so project and global-cap dialogs use the same title, message template, save label, and cancel label. +*/ +function getConcurrencyChangeSummary(t: ReturnType["t"], setting: string, oldValue: number, newValue: number) { + return t( + "commandCenter.controls.concurrency.confirmChangeSummaryItem", + "{{setting}} from {{oldValue}} to {{newValue}}", + { setting, oldValue, newValue }, + ); +} + /* FNXC:GlobalConcurrencyControls 2026-06-29-10:30: FN-7235 keeps the footer current-use marker consistent with FN-7160 Command Center behavior: it shows absolute utilization on a 0..cap scale. Do not subtract the range input floor of 1, because one running agent must render above zero even though the editable slider cannot be set to 0. @@ -82,31 +105,27 @@ FN-6863 raises the footer concurrency sliders' base drag ceiling to 50 for max t */ export const EngineControlMenu = forwardRef(function EngineControlMenu({ projectId }, ref) { const { t } = useTranslation("app"); + const { confirm } = useConfirm(); const menuRef = useRef(null); const [open, setOpen] = useState(false); const { globalPaused, enginePaused, toggleGlobalPause, toggleEnginePause, refresh } = useAppSettings(projectId); const [concurrencyState, setConcurrencyState] = useState>({ status: "idle", data: null, error: null }); const [concurrencyDirty, setConcurrencyDirty] = useState(false); const [concurrencySaveState, setConcurrencySaveState] = useState<"idle" | "saving" | "saved" | "error">("idle"); + const persistedProjectConcurrencyRef = useRef(DEFAULT_CONCURRENCY_VALUES); const projectConcurrencySaveTimeoutRef = useRef | null>(null); const pendingProjectConcurrencySaveRef = useRef(null); - // FNXC:EngineControls 2026-06-27-11:15: Closing the footer popover must not discard a just-dragged per-project concurrency value; flush the pending debounce before any explicit, outside-click, Escape, or trigger-close path hides the menu. + const projectConcurrencyConfirmOpenRef = useRef(false); + const projectConcurrencyConfirmTokenRef = useRef(0); + const [pendingGlobalConcurrencyValue, setPendingGlobalConcurrencyValue] = useState(null); + const [globalConcurrencyDirty, setGlobalConcurrencyDirty] = useState(false); + const [globalConcurrencyConfirmOpen, setGlobalConcurrencyConfirmOpen] = useState(false); + const globalConcurrencyConfirmOpenRef = useRef(false); + const globalConcurrencyConfirmTokenRef = useRef(0); + // FNXC:EngineControls 2026-06-29-00:00: Footer per-project concurrency sliders affect live scheduler capacity, so settled edits must be confirmed before persisting; close, Escape, outside-click, backdrop, and cancel revert to the last loaded values instead of silently saving. // FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Fetch is gated on the menu being open; the hook flushes any pending debounced write when `open` flips false. const gc = useGlobalConcurrency({ activeWhen: open }); - const saveProjectConcurrencyValues = useCallback((values: ConcurrencyValues) => { - setConcurrencySaveState("saving"); - void updateSettings(values, projectId) - .then(async () => { - await refresh(); - setConcurrencyDirty(false); - setConcurrencySaveState("saved"); - }) - .catch(() => { - setConcurrencySaveState("error"); - }); - }, [projectId, refresh]); - const clearProjectConcurrencySaveTimeout = useCallback(() => { if (projectConcurrencySaveTimeoutRef.current) { clearTimeout(projectConcurrencySaveTimeoutRef.current); @@ -114,23 +133,47 @@ export const EngineControlMenu = forwardRef { - const pendingValues = pendingProjectConcurrencySaveRef.current; - if (!pendingValues) return; + const revertPendingProjectConcurrencyEdit = useCallback(() => { clearProjectConcurrencySaveTimeout(); pendingProjectConcurrencySaveRef.current = null; - saveProjectConcurrencyValues(pendingValues); - }, [clearProjectConcurrencySaveTimeout, saveProjectConcurrencyValues]); + projectConcurrencyConfirmOpenRef.current = false; + projectConcurrencyConfirmTokenRef.current += 1; + setConcurrencyState((current) => ( + current.data + ? { status: "loaded", data: persistedProjectConcurrencyRef.current, error: null } + : current + )); + setConcurrencyDirty(false); + setConcurrencySaveState("idle"); + }, [clearProjectConcurrencySaveTimeout]); + + const revertPendingGlobalConcurrencyEdit = useCallback(() => { + globalConcurrencyConfirmOpenRef.current = false; + globalConcurrencyConfirmTokenRef.current += 1; + setGlobalConcurrencyConfirmOpen(false); + setPendingGlobalConcurrencyValue(null); + setGlobalConcurrencyDirty(false); + }, []); const closeMenu = useCallback(() => { - flushProjectConcurrencySave(); + if (concurrencyDirty || pendingProjectConcurrencySaveRef.current || projectConcurrencyConfirmOpenRef.current) { + revertPendingProjectConcurrencyEdit(); + } + if (globalConcurrencyDirty || pendingGlobalConcurrencyValue !== null || globalConcurrencyConfirmOpenRef.current) { + revertPendingGlobalConcurrencyEdit(); + } setOpen(false); - }, [flushProjectConcurrencySave]); + }, [concurrencyDirty, globalConcurrencyDirty, pendingGlobalConcurrencyValue, revertPendingGlobalConcurrencyEdit, revertPendingProjectConcurrencyEdit]); const openMenu = useCallback(() => setOpen(true), []); const toggleMenu = useCallback(() => { - if (open) flushProjectConcurrencySave(); + if (open && (concurrencyDirty || pendingProjectConcurrencySaveRef.current || projectConcurrencyConfirmOpenRef.current)) { + revertPendingProjectConcurrencyEdit(); + } + if (open && (globalConcurrencyDirty || pendingGlobalConcurrencyValue !== null || globalConcurrencyConfirmOpenRef.current)) { + revertPendingGlobalConcurrencyEdit(); + } setOpen((current) => !current); - }, [flushProjectConcurrencySave, open]); + }, [concurrencyDirty, globalConcurrencyDirty, open, pendingGlobalConcurrencyValue, revertPendingGlobalConcurrencyEdit, revertPendingProjectConcurrencyEdit]); useImperativeHandle(ref, () => ({ open: openMenu, @@ -142,7 +185,11 @@ export const EngineControlMenu = forwardRef { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + const target = event.target; + if ((projectConcurrencyConfirmOpenRef.current || globalConcurrencyConfirmOpenRef.current) && target instanceof Element && target.closest(".confirm-dialog-overlay, .confirm-dialog")) { + return; + } + if (menuRef.current && target instanceof Node && !menuRef.current.contains(target)) { closeMenu(); } }; @@ -169,13 +216,17 @@ export const EngineControlMenu = forwardRef { - if (!open || !concurrencyDirty || !concurrencyState.data) return; + if (!open || !concurrencyDirty || !concurrencyState.data || projectConcurrencyConfirmOpenRef.current) return; const values = concurrencyState.data; + const confirmToken = projectConcurrencyConfirmTokenRef.current; pendingProjectConcurrencySaveRef.current = values; projectConcurrencySaveTimeoutRef.current = setTimeout(() => { pendingProjectConcurrencySaveRef.current = null; projectConcurrencySaveTimeoutRef.current = null; - saveProjectConcurrencyValues(values); + const persisted = persistedProjectConcurrencyRef.current; + const changedKeys = getChangedConcurrencyKeys(values, persisted); + if (changedKeys.length === 0) { + setConcurrencyDirty(false); + setConcurrencySaveState("idle"); + return; + } + + projectConcurrencyConfirmOpenRef.current = true; + const changeSummary = changedKeys.map((key) => { + const labelMeta = CONCURRENCY_SETTING_LABEL_KEYS[key]; + return getConcurrencyChangeSummary(t, t(labelMeta.key, labelMeta.defaultValue), persisted[key], values[key]); + }); + const message = changedKeys.length === 1 + ? t( + "commandCenter.controls.concurrency.confirmMessage", + "Change {{setting}}?", + { setting: changeSummary[0] }, + ) + : t( + "commandCenter.controls.concurrency.confirmMultipleMessage", + "Change these concurrency settings: {{settings}}?", + { settings: changeSummary.join("; ") }, + ); + + void confirm({ + title: t("commandCenter.controls.concurrency.confirmTitle", "Confirm concurrency change"), + message, + confirmLabel: t("commandCenter.controls.concurrency.confirmSave", "Save change"), + cancelLabel: t("commandCenter.controls.concurrency.confirmCancel", "Cancel"), + }).then((confirmed) => { + projectConcurrencyConfirmOpenRef.current = false; + if (projectConcurrencyConfirmTokenRef.current !== confirmToken || !open) return; + if (!confirmed) { + setConcurrencyState({ status: "loaded", data: persistedProjectConcurrencyRef.current, error: null }); + setConcurrencyDirty(false); + setConcurrencySaveState("idle"); + return; + } + + setConcurrencySaveState("saving"); + void updateSettings(values, projectId) + .then(async () => { + await refresh(); + persistedProjectConcurrencyRef.current = values; + setConcurrencyDirty(false); + setConcurrencySaveState("saved"); + }) + .catch(() => { + setConcurrencySaveState("error"); + }); + }); }, CONCURRENCY_SAVE_DEBOUNCE_MS); return () => { clearProjectConcurrencySaveTimeout(); @@ -209,7 +312,61 @@ export const EngineControlMenu = forwardRef { + if (!globalConcurrencyDirty || pendingGlobalConcurrencyValue === null || !gc.interactive || globalConcurrencyConfirmOpenRef.current) return; + const nextValue = pendingGlobalConcurrencyValue; + const persistedValue = gc.value; + const confirmToken = globalConcurrencyConfirmTokenRef.current; + const timeoutId = setTimeout(() => { + if (nextValue === persistedValue) { + setPendingGlobalConcurrencyValue(null); + setGlobalConcurrencyDirty(false); + return; + } + + globalConcurrencyConfirmOpenRef.current = true; + setGlobalConcurrencyConfirmOpen(true); + const changeSummary = getConcurrencyChangeSummary( + t, + t("settings.scheduling.globalMaxConcurrent", "Global Max Concurrent"), + persistedValue, + nextValue, + ); + void confirm({ + title: t("commandCenter.controls.concurrency.confirmTitle", "Confirm concurrency change"), + message: t( + "commandCenter.controls.concurrency.confirmMessage", + "Change {{setting}}?", + { setting: changeSummary }, + ), + confirmLabel: t("commandCenter.controls.concurrency.confirmSave", "Save change"), + cancelLabel: t("commandCenter.controls.concurrency.confirmCancel", "Cancel"), + }).then((confirmed) => { + globalConcurrencyConfirmOpenRef.current = false; + setGlobalConcurrencyConfirmOpen(false); + if (globalConcurrencyConfirmTokenRef.current !== confirmToken || !open) return; + if (confirmed) { + gc.setValue(String(nextValue)); + } + setPendingGlobalConcurrencyValue(null); + setGlobalConcurrencyDirty(false); + }); + }, CONCURRENCY_SAVE_DEBOUNCE_MS); + return () => clearTimeout(timeoutId); + }, [confirm, gc.interactive, gc.setValue, gc.value, globalConcurrencyDirty, open, pendingGlobalConcurrencyValue, t]); + + const updateGlobalConcurrencyValue = (rawValue: string) => { + if (!gc.interactive || globalConcurrencyConfirmOpenRef.current) return; + const nextValue = clamp(Number(rawValue), gc.min, Math.max(gc.sliderMax, gc.value)); + setPendingGlobalConcurrencyValue(nextValue); + setGlobalConcurrencyDirty(true); + }; const updateConcurrencyValue = (key: keyof ConcurrencyValues, rawValue: string, min: number, max: number) => { const nextValue = clamp(Number(rawValue), min, max); @@ -247,8 +404,10 @@ export const EngineControlMenu = forwardRef {t("settings.scheduling.maximumConcurrentAgentsAcrossAllProjects", "Maximum concurrent agents across all projects")} - {gc.value} + {globalSliderValue} {globalCountsLoaded ? ( @@ -334,10 +493,10 @@ export const EngineControlMenu = forwardRef gc.setValue(event.target.value)} + max={globalSliderMax} + value={globalSliderValue} + disabled={!gc.interactive || globalConcurrencyConfirmOpen} + onChange={(event) => updateGlobalConcurrencyValue(event.target.value)} /> {globalCountsLoaded ? ( ({ })); async function openMenu(projectId: string | undefined = "proj_123") { - render(); + render( + + + , + ); fireEvent.click(screen.getByTestId("engine-control-menu-trigger")); await screen.findByTestId("engine-control-menu"); } @@ -99,7 +104,7 @@ describe("EngineControlMenu", () => { await waitFor(() => expect(screen.queryByTestId("engine-control-menu")).not.toBeInTheDocument()); }); - it("flushes pending project concurrency changes when the explicit close button is clicked", async () => { + it("reverts pending project concurrency changes when the explicit close button is clicked", async () => { await openMenu(); const maxConcurrent = await screen.findByLabelText(/max concurrent tasks/i); @@ -108,14 +113,11 @@ describe("EngineControlMenu", () => { fireEvent.change(maxConcurrent, { target: { value: "7" } }); fireEvent.click(screen.getByTestId("engine-control-menu-close")); - expect(legacyMocks.updateSettings).toHaveBeenCalledWith( - { maxConcurrent: 7, maxTriageConcurrent: 1, maxWorktrees: 4 }, - "proj_123", - ); await act(async () => { await vi.advanceTimersByTimeAsync(500); }); - expect(legacyMocks.updateSettings).toHaveBeenCalledTimes(1); + expect(legacyMocks.updateSettings).not.toHaveBeenCalled(); + expect(screen.queryByTestId("engine-control-menu")).not.toBeInTheDocument(); }); it("keeps the close button available when concurrency settings fail to load", async () => { @@ -164,7 +166,11 @@ describe("EngineControlMenu", () => { apiMocks.fetchSettings.mockResolvedValue({ ...defaultSettings, globalPause: true, enginePaused: true }); legacyMocks.fetchConfig.mockResolvedValue({ maxConcurrent: 2, rootDir: "/workspace/project" }); legacyMocks.fetchSettings.mockResolvedValue({ ...defaultSettings }); - render(); + render( + + + , + ); fireEvent.click(screen.getAllByTestId("engine-control-menu-trigger")[1]); await waitFor(() => expect(screen.getAllByTestId("engine-control-pause-triage-btn")).toHaveLength(2)); @@ -173,7 +179,58 @@ describe("EngineControlMenu", () => { expect(pauseButton).toHaveTextContent(/resume scheduling/i); }); - it("persists debounced concurrency and worktree slider changes and refreshes settings", async () => { + it("cancels a confirmed project concurrency edit by reverting to persisted values without saving", async () => { + await openMenu(); + + const maxConcurrent = await screen.findByLabelText(/max concurrent tasks/i); + vi.useFakeTimers(); + + fireEvent.change(maxConcurrent, { target: { value: "7" } }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + expect(screen.getByRole("dialog", { name: /confirm concurrency change/i })).toHaveTextContent("Max concurrent tasks from 2 to 7"); + vi.useRealTimers(); + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + + await waitFor(() => expect(screen.queryByRole("dialog", { name: /confirm concurrency change/i })).not.toBeInTheDocument()); + await waitFor(() => expect(maxConcurrent).toHaveValue("2")); + expect(legacyMocks.updateSettings).not.toHaveBeenCalled(); + }); + + it("does not silently save project concurrency edits on Escape or outside dismissal", async () => { + await openMenu(); + const maxConcurrent = await screen.findByLabelText(/max concurrent tasks/i); + vi.useFakeTimers(); + + fireEvent.change(maxConcurrent, { target: { value: "7" } }); + fireEvent.keyDown(document, { key: "Escape" }); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + expect(legacyMocks.updateSettings).not.toHaveBeenCalled(); + expect(screen.queryByTestId("engine-control-menu")).not.toBeInTheDocument(); + + vi.useRealTimers(); + cleanup(); + legacyMocks.updateSettings.mockClear(); + await openMenu(); + const reopenedMaxConcurrent = await screen.findByLabelText(/max concurrent tasks/i); + vi.useFakeTimers(); + + fireEvent.change(reopenedMaxConcurrent, { target: { value: "8" } }); + fireEvent.mouseDown(document.body); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + expect(legacyMocks.updateSettings).not.toHaveBeenCalled(); + expect(screen.queryByTestId("engine-control-menu")).not.toBeInTheDocument(); + }); + + it("confirms debounced concurrency and worktree slider changes before persisting and refreshing settings", async () => { legacyMocks.fetchSettings.mockResolvedValue({ ...defaultSettings, maxConcurrent: 60, @@ -203,10 +260,20 @@ describe("EngineControlMenu", () => { await vi.advanceTimersByTimeAsync(500); }); - expect(legacyMocks.updateSettings).toHaveBeenCalledWith( + expect(screen.getByRole("dialog", { name: /confirm concurrency change/i })).toHaveTextContent("Max concurrent tasks from 60 to 9"); + expect(screen.getByRole("dialog", { name: /confirm concurrency change/i })).toHaveTextContent("Max triage concurrent from 70 to 4"); + expect(screen.getByRole("dialog", { name: /confirm concurrency change/i })).toHaveTextContent("Max worktrees from 80 to 8"); + expect(legacyMocks.updateSettings).not.toHaveBeenCalled(); + + vi.useRealTimers(); + const saveButton = screen.getByRole("button", { name: /save change/i }); + fireEvent.mouseDown(saveButton); + fireEvent.click(saveButton); + + await waitFor(() => expect(legacyMocks.updateSettings).toHaveBeenCalledWith( { maxConcurrent: 9, maxTriageConcurrent: 4, maxWorktrees: 8 }, "proj_123", - ); + )); expect(apiMocks.fetchSettings).toHaveBeenCalledTimes(2); }); @@ -224,6 +291,194 @@ describe("EngineControlMenu", () => { expect(screen.getByLabelText(/max worktrees/i)).toHaveAttribute("max", "50"); }); + it("confirms footer global cap edits before writing through the shared hook", async () => { + await openMenu(); + + const globalMaxConcurrent = await screen.findByLabelText(/maximum concurrent agents across all projects/i); + vi.useFakeTimers(); + + fireEvent.change(globalMaxConcurrent, { target: { value: "9" } }); + + expect(globalMaxConcurrent).toHaveValue("9"); + expect(globalMaxConcurrent.closest("label")).toHaveTextContent("9"); + expect(legacyMocks.updateGlobalConcurrency).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + const dialog = screen.getByRole("dialog", { name: /confirm concurrency change/i }); + expect(dialog).toHaveTextContent("Change Global Max Concurrent from 6 to 9?"); + expect(screen.getByRole("button", { name: /save change/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); + expect(globalMaxConcurrent).toBeDisabled(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /save change/i })); + await Promise.resolve(); + }); + expect(screen.queryByRole("dialog", { name: /confirm concurrency change/i })).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + await Promise.resolve(); + }); + + expect(legacyMocks.updateGlobalConcurrency).toHaveBeenCalledWith({ globalMaxConcurrent: 9 }); + }); + + it("prevents duplicate footer confirmation dialogs while a concurrency confirmation is open", async () => { + await openMenu(); + + const maxConcurrent = await screen.findByLabelText(/max concurrent tasks/i); + const globalMaxConcurrent = screen.getByLabelText(/maximum concurrent agents across all projects/i); + vi.useFakeTimers(); + + fireEvent.change(maxConcurrent, { target: { value: "7" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + expect(screen.getAllByRole("dialog", { name: /confirm concurrency change/i })).toHaveLength(1); + + fireEvent.change(maxConcurrent, { target: { value: "8" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + expect(screen.getAllByRole("dialog", { name: /confirm concurrency change/i })).toHaveLength(1); + vi.useRealTimers(); + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + await waitFor(() => expect(screen.queryByRole("dialog", { name: /confirm concurrency change/i })).not.toBeInTheDocument()); + + vi.useFakeTimers(); + fireEvent.change(globalMaxConcurrent, { target: { value: "9" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + expect(screen.getAllByRole("dialog", { name: /confirm concurrency change/i })).toHaveLength(1); + fireEvent.change(globalMaxConcurrent, { target: { value: "10" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + expect(screen.getAllByRole("dialog", { name: /confirm concurrency change/i })).toHaveLength(1); + expect(legacyMocks.updateSettings).not.toHaveBeenCalled(); + expect(legacyMocks.updateGlobalConcurrency).not.toHaveBeenCalled(); + }); + + it("flushes already-confirmed global cap saves when the footer closes", async () => { + await openMenu(); + + const globalMaxConcurrent = await screen.findByLabelText(/maximum concurrent agents across all projects/i); + vi.useFakeTimers(); + + fireEvent.change(globalMaxConcurrent, { target: { value: "9" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /save change/i })); + await Promise.resolve(); + }); + + fireEvent.click(screen.getByTestId("engine-control-menu-close")); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + await Promise.resolve(); + }); + + expect(screen.queryByTestId("engine-control-menu")).not.toBeInTheDocument(); + expect(legacyMocks.updateGlobalConcurrency).toHaveBeenCalledWith({ globalMaxConcurrent: 9 }); + }); + + it("cancels footer global cap edits without triggering a global write", async () => { + await openMenu(); + + const globalMaxConcurrent = await screen.findByLabelText(/maximum concurrent agents across all projects/i); + vi.useFakeTimers(); + + fireEvent.change(globalMaxConcurrent, { target: { value: "8" } }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + expect(screen.getByRole("dialog", { name: /confirm concurrency change/i })).toHaveTextContent("Global Max Concurrent from 6 to 8"); + vi.useRealTimers(); + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + + await waitFor(() => expect(screen.queryByRole("dialog", { name: /confirm concurrency change/i })).not.toBeInTheDocument()); + await waitFor(() => expect(globalMaxConcurrent).toHaveValue("6")); + expect(legacyMocks.updateGlobalConcurrency).not.toHaveBeenCalled(); + }); + + it("does not prompt or write when a footer global cap edit matches the persisted value", async () => { + await openMenu(); + + const globalMaxConcurrent = await screen.findByLabelText(/maximum concurrent agents across all projects/i); + vi.useFakeTimers(); + + fireEvent.change(globalMaxConcurrent, { target: { value: "6" } }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + expect(screen.queryByRole("dialog", { name: /confirm concurrency change/i })).not.toBeInTheDocument(); + expect(legacyMocks.updateGlobalConcurrency).not.toHaveBeenCalled(); + }); + + it("keeps loading and error global cap states disabled so they cannot prompt", async () => { + let resolveGlobalConcurrency!: (value: { + globalMaxConcurrent: number; + currentlyActive: number; + queuedCount: number; + projectsActive: Record; + }) => void; + legacyMocks.fetchGlobalConcurrency.mockReturnValue(new Promise((resolve) => { + resolveGlobalConcurrency = resolve; + })); + + await openMenu(); + + const loadingGlobalMaxConcurrent = await screen.findByLabelText(/maximum concurrent agents across all projects/i); + expect(loadingGlobalMaxConcurrent).toBeDisabled(); + + vi.useFakeTimers(); + fireEvent.change(loadingGlobalMaxConcurrent, { target: { value: "7" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + expect(screen.queryByRole("dialog", { name: /confirm concurrency change/i })).not.toBeInTheDocument(); + expect(legacyMocks.updateGlobalConcurrency).not.toHaveBeenCalled(); + + await act(async () => { + resolveGlobalConcurrency({ + globalMaxConcurrent: 6, + currentlyActive: 3, + queuedCount: 0, + projectsActive: { proj_123: 2 }, + }); + }); + + vi.useRealTimers(); + cleanup(); + legacyMocks.updateGlobalConcurrency.mockClear(); + legacyMocks.fetchGlobalConcurrency.mockRejectedValue(new Error("global concurrency unavailable")); + + await openMenu(); + + const errorGlobalMaxConcurrent = await screen.findByLabelText(/maximum concurrent agents across all projects/i); + await screen.findByRole("alert"); + expect(errorGlobalMaxConcurrent).toBeDisabled(); + + vi.useFakeTimers(); + fireEvent.change(errorGlobalMaxConcurrent, { target: { value: "7" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + expect(screen.queryByRole("dialog", { name: /confirm concurrency change/i })).not.toBeInTheDocument(); + expect(legacyMocks.updateGlobalConcurrency).not.toHaveBeenCalled(); + }); + it("renders running counts and current-use markers with clamped absolute utilization", async () => { legacyMocks.fetchSettings.mockResolvedValue({ ...defaultSettings, @@ -357,7 +612,7 @@ describe("EngineControlMenu", () => { expect(screen.queryByTestId("engine-control-project-use-marker")).not.toBeInTheDocument(); }); - it("persists a slider value of 50 through the debounced settings save", async () => { + it("persists a slider value of 50 after confirmation", async () => { await openMenu(); const maxConcurrent = await screen.findByLabelText(/max concurrent tasks/i); @@ -370,11 +625,13 @@ describe("EngineControlMenu", () => { await act(async () => { await vi.advanceTimersByTimeAsync(500); }); + vi.useRealTimers(); + fireEvent.click(screen.getByRole("button", { name: /save change/i })); - expect(legacyMocks.updateSettings).toHaveBeenCalledWith( + await waitFor(() => expect(legacyMocks.updateSettings).toHaveBeenCalledWith( { maxConcurrent: 50, maxTriageConcurrent: 1, maxWorktrees: 4 }, "proj_123", - ); + )); }); it("renders a load error state without crashing", async () => {