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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7248-footer-concurrency-confirmation.md
Normal file
7
.changeset/fn-7248-footer-concurrency-confirmation.md
Normal file
@@ -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.
|
||||||
@@ -1039,7 +1039,8 @@ Use this panel when upgrading a project with pre-FN-6245/FN-6277 in-review rows
|
|||||||
### Executor footer engine controls
|
### Executor footer engine controls
|
||||||
|
|
||||||
<!-- FNXC:ExecutorStatusBar 2026-06-29-00:00: FN-7235 documents that footer concurrency current-use dots use the same absolute utilization math as Command Center controls, so running-agent counts visually align with the slider track instead of the editable slider minimum. -->
|
<!-- FNXC:ExecutorStatusBar 2026-06-29-00:00: FN-7235 documents that footer concurrency current-use dots use the same absolute utilization math as Command Center controls, so running-agent counts visually align with the slider track instead of the editable slider minimum. -->
|
||||||
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.
|
<!-- FNXC:ExecutorStatusBar 2026-06-29-19:09: FN-7248 makes footer concurrency edits confirmation-gated like Command Center. Closing the popover, outside-clicking, pressing Escape, dismissing the backdrop, or unmounting must revert unconfirmed slider edits instead of saving them. -->
|
||||||
|
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.
|
||||||
|
|
||||||
<!-- FNXC:ExecutorStatusBar 2026-06-27-00:00: FN-7163 makes footer stats loading initial-only so routine heartbeat refreshes keep the populated footer and open concurrency popover mounted instead of blinking to the loading branch. -->
|
<!-- FNXC:ExecutorStatusBar 2026-06-27-00:00: FN-7163 makes footer stats loading initial-only so routine heartbeat refreshes keep the populated footer and open concurrency popover mounted instead of blinking to the loading branch. -->
|
||||||
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.
|
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.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { DEFAULT_PROJECT_SETTINGS } from "@fusion/core";
|
|||||||
import { Pause, Play, SlidersHorizontal, Square, X } from "lucide-react";
|
import { Pause, Play, SlidersHorizontal, Square, X } from "lucide-react";
|
||||||
import { fetchConfig, fetchSettings, updateSettings } from "../api/legacy";
|
import { fetchConfig, fetchSettings, updateSettings } from "../api/legacy";
|
||||||
import { useAppSettings } from "../hooks/useAppSettings";
|
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).
|
// 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";
|
import { useGlobalConcurrency } from "../hooks/useGlobalConcurrency";
|
||||||
|
|
||||||
@@ -42,6 +43,12 @@ const CONCURRENCY_SLIDER_LIMITS: Record<keyof ConcurrencyValues, { min: number;
|
|||||||
maxWorktrees: { min: 1, max: 50 },
|
maxWorktrees: { min: 1, max: 50 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CONCURRENCY_SETTING_LABEL_KEYS: Record<keyof ConcurrencyValues, { key: string; defaultValue: string }> = {
|
||||||
|
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) {
|
function clamp(value: number, min: number, max: number) {
|
||||||
return Math.min(max, Math.max(min, value));
|
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;
|
return error instanceof Error ? error.message : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getChangedConcurrencyKeys(values: ConcurrencyValues, persisted: ConcurrencyValues) {
|
||||||
|
return (Object.keys(values) as Array<keyof ConcurrencyValues>).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<typeof useTranslation>["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:
|
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.
|
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<EngineControlMenuHandle, EngineControlMenuProps>(function EngineControlMenu({ projectId }, ref) {
|
export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineControlMenuProps>(function EngineControlMenu({ projectId }, ref) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
|
const { confirm } = useConfirm();
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { globalPaused, enginePaused, toggleGlobalPause, toggleEnginePause, refresh } = useAppSettings(projectId);
|
const { globalPaused, enginePaused, toggleGlobalPause, toggleEnginePause, refresh } = useAppSettings(projectId);
|
||||||
const [concurrencyState, setConcurrencyState] = useState<AsyncState<ConcurrencyValues>>({ status: "idle", data: null, error: null });
|
const [concurrencyState, setConcurrencyState] = useState<AsyncState<ConcurrencyValues>>({ status: "idle", data: null, error: null });
|
||||||
const [concurrencyDirty, setConcurrencyDirty] = useState(false);
|
const [concurrencyDirty, setConcurrencyDirty] = useState(false);
|
||||||
const [concurrencySaveState, setConcurrencySaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
const [concurrencySaveState, setConcurrencySaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
const persistedProjectConcurrencyRef = useRef<ConcurrencyValues>(DEFAULT_CONCURRENCY_VALUES);
|
||||||
const projectConcurrencySaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const projectConcurrencySaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pendingProjectConcurrencySaveRef = useRef<ConcurrencyValues | null>(null);
|
const pendingProjectConcurrencySaveRef = useRef<ConcurrencyValues | null>(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<number | null>(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.
|
// 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 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(() => {
|
const clearProjectConcurrencySaveTimeout = useCallback(() => {
|
||||||
if (projectConcurrencySaveTimeoutRef.current) {
|
if (projectConcurrencySaveTimeoutRef.current) {
|
||||||
clearTimeout(projectConcurrencySaveTimeoutRef.current);
|
clearTimeout(projectConcurrencySaveTimeoutRef.current);
|
||||||
@@ -114,23 +133,47 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const flushProjectConcurrencySave = useCallback(() => {
|
const revertPendingProjectConcurrencyEdit = useCallback(() => {
|
||||||
const pendingValues = pendingProjectConcurrencySaveRef.current;
|
|
||||||
if (!pendingValues) return;
|
|
||||||
clearProjectConcurrencySaveTimeout();
|
clearProjectConcurrencySaveTimeout();
|
||||||
pendingProjectConcurrencySaveRef.current = null;
|
pendingProjectConcurrencySaveRef.current = null;
|
||||||
saveProjectConcurrencyValues(pendingValues);
|
projectConcurrencyConfirmOpenRef.current = false;
|
||||||
}, [clearProjectConcurrencySaveTimeout, saveProjectConcurrencyValues]);
|
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(() => {
|
const closeMenu = useCallback(() => {
|
||||||
flushProjectConcurrencySave();
|
if (concurrencyDirty || pendingProjectConcurrencySaveRef.current || projectConcurrencyConfirmOpenRef.current) {
|
||||||
|
revertPendingProjectConcurrencyEdit();
|
||||||
|
}
|
||||||
|
if (globalConcurrencyDirty || pendingGlobalConcurrencyValue !== null || globalConcurrencyConfirmOpenRef.current) {
|
||||||
|
revertPendingGlobalConcurrencyEdit();
|
||||||
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [flushProjectConcurrencySave]);
|
}, [concurrencyDirty, globalConcurrencyDirty, pendingGlobalConcurrencyValue, revertPendingGlobalConcurrencyEdit, revertPendingProjectConcurrencyEdit]);
|
||||||
const openMenu = useCallback(() => setOpen(true), []);
|
const openMenu = useCallback(() => setOpen(true), []);
|
||||||
const toggleMenu = useCallback(() => {
|
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);
|
setOpen((current) => !current);
|
||||||
}, [flushProjectConcurrencySave, open]);
|
}, [concurrencyDirty, globalConcurrencyDirty, open, pendingGlobalConcurrencyValue, revertPendingGlobalConcurrencyEdit, revertPendingProjectConcurrencyEdit]);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
open: openMenu,
|
open: openMenu,
|
||||||
@@ -142,7 +185,11 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
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();
|
closeMenu();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -169,13 +216,17 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
try {
|
try {
|
||||||
const [config, settings] = await Promise.all([fetchConfig(projectId), fetchSettings(projectId)]);
|
const [config, settings] = await Promise.all([fetchConfig(projectId), fetchSettings(projectId)]);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
const persistedValues = {
|
||||||
|
maxConcurrent: settings.maxConcurrent ?? config.maxConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxConcurrent,
|
||||||
|
maxTriageConcurrent: settings.maxTriageConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxTriageConcurrent,
|
||||||
|
maxWorktrees: settings.maxWorktrees ?? DEFAULT_CONCURRENCY_VALUES.maxWorktrees,
|
||||||
|
};
|
||||||
|
persistedProjectConcurrencyRef.current = persistedValues;
|
||||||
|
pendingProjectConcurrencySaveRef.current = null;
|
||||||
|
projectConcurrencyConfirmOpenRef.current = false;
|
||||||
setConcurrencyState({
|
setConcurrencyState({
|
||||||
status: "loaded",
|
status: "loaded",
|
||||||
data: {
|
data: persistedValues,
|
||||||
maxConcurrent: settings.maxConcurrent ?? config.maxConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxConcurrent,
|
|
||||||
maxTriageConcurrent: settings.maxTriageConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxTriageConcurrent,
|
|
||||||
maxWorktrees: settings.maxWorktrees ?? DEFAULT_CONCURRENCY_VALUES.maxWorktrees,
|
|
||||||
},
|
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -195,13 +246,65 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
}, [open, projectId, t]);
|
}, [open, projectId, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !concurrencyDirty || !concurrencyState.data) return;
|
if (!open || !concurrencyDirty || !concurrencyState.data || projectConcurrencyConfirmOpenRef.current) return;
|
||||||
const values = concurrencyState.data;
|
const values = concurrencyState.data;
|
||||||
|
const confirmToken = projectConcurrencyConfirmTokenRef.current;
|
||||||
pendingProjectConcurrencySaveRef.current = values;
|
pendingProjectConcurrencySaveRef.current = values;
|
||||||
projectConcurrencySaveTimeoutRef.current = setTimeout(() => {
|
projectConcurrencySaveTimeoutRef.current = setTimeout(() => {
|
||||||
pendingProjectConcurrencySaveRef.current = null;
|
pendingProjectConcurrencySaveRef.current = null;
|
||||||
projectConcurrencySaveTimeoutRef.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);
|
}, CONCURRENCY_SAVE_DEBOUNCE_MS);
|
||||||
return () => {
|
return () => {
|
||||||
clearProjectConcurrencySaveTimeout();
|
clearProjectConcurrencySaveTimeout();
|
||||||
@@ -209,7 +312,61 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
pendingProjectConcurrencySaveRef.current = null;
|
pendingProjectConcurrencySaveRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [clearProjectConcurrencySaveTimeout, concurrencyDirty, concurrencyState.data, open, saveProjectConcurrencyValues]);
|
}, [clearProjectConcurrencySaveTimeout, concurrencyDirty, concurrencyState.data, confirm, open, projectId, refresh, t]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:GlobalConcurrencyControls 2026-06-29-00:00:
|
||||||
|
The footer keeps global-cap edits in local pending state until the operator confirms. Calling useGlobalConcurrency.setValue() immediately would enter the shared hook's debounce and close/unmount flush path, which can persist a footer drag from close, Escape, outside-click, backdrop, or cancel before consent.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
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 updateConcurrencyValue = (key: keyof ConcurrencyValues, rawValue: string, min: number, max: number) => {
|
||||||
const nextValue = clamp(Number(rawValue), min, max);
|
const nextValue = clamp(Number(rawValue), min, max);
|
||||||
@@ -247,8 +404,10 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
: t("commandCenter.controls.status.ready", "Ready");
|
: t("commandCenter.controls.status.ready", "Ready");
|
||||||
const globalCountsLoaded = gc.status === "loaded";
|
const globalCountsLoaded = gc.status === "loaded";
|
||||||
const projectActive = gc.projectActiveCount(projectId);
|
const projectActive = gc.projectActiveCount(projectId);
|
||||||
|
const globalSliderValue = pendingGlobalConcurrencyValue ?? gc.value;
|
||||||
|
const globalSliderMax = Math.max(gc.sliderMax, globalSliderValue);
|
||||||
const maxConcurrentSliderMax = getConcurrencySliderMax("maxConcurrent", concurrencyValues.maxConcurrent);
|
const maxConcurrentSliderMax = getConcurrencySliderMax("maxConcurrent", concurrencyValues.maxConcurrent);
|
||||||
const globalUseMarkerRatio = getUseMarkerRatio(gc.currentlyActive, gc.sliderMax);
|
const globalUseMarkerRatio = getUseMarkerRatio(gc.currentlyActive, globalSliderMax);
|
||||||
const projectUseMarkerRatio = getUseMarkerRatio(projectActive, maxConcurrentSliderMax);
|
const projectUseMarkerRatio = getUseMarkerRatio(projectActive, maxConcurrentSliderMax);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -321,7 +480,7 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
<label className="engine-control-menu__slider" htmlFor="engine-control-global-max-concurrent">
|
<label className="engine-control-menu__slider" htmlFor="engine-control-global-max-concurrent">
|
||||||
<span className="engine-control-menu__slider-label">
|
<span className="engine-control-menu__slider-label">
|
||||||
{t("settings.scheduling.maximumConcurrentAgentsAcrossAllProjects", "Maximum concurrent agents across all projects")}
|
{t("settings.scheduling.maximumConcurrentAgentsAcrossAllProjects", "Maximum concurrent agents across all projects")}
|
||||||
<strong>{gc.value}</strong>
|
<strong>{globalSliderValue}</strong>
|
||||||
</span>
|
</span>
|
||||||
{globalCountsLoaded ? (
|
{globalCountsLoaded ? (
|
||||||
<span className="engine-control-menu__slider-meta" data-testid="engine-control-global-running">
|
<span className="engine-control-menu__slider-meta" data-testid="engine-control-global-running">
|
||||||
@@ -334,10 +493,10 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
className="engine-control-menu__range input"
|
className="engine-control-menu__range input"
|
||||||
type="range"
|
type="range"
|
||||||
min={gc.min}
|
min={gc.min}
|
||||||
max={gc.sliderMax}
|
max={globalSliderMax}
|
||||||
value={gc.value}
|
value={globalSliderValue}
|
||||||
disabled={!gc.interactive}
|
disabled={!gc.interactive || globalConcurrencyConfirmOpen}
|
||||||
onChange={(event) => gc.setValue(event.target.value)}
|
onChange={(event) => updateGlobalConcurrencyValue(event.target.value)}
|
||||||
/>
|
/>
|
||||||
{globalCountsLoaded ? (
|
{globalCountsLoaded ? (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor, act, cleanup } from "@testing-library/react";
|
||||||
import { EngineControlMenu } from "../EngineControlMenu";
|
import { EngineControlMenu } from "../EngineControlMenu";
|
||||||
|
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
|
||||||
|
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
@@ -34,7 +35,11 @@ vi.mock("../../versionCheck", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
async function openMenu(projectId: string | undefined = "proj_123") {
|
async function openMenu(projectId: string | undefined = "proj_123") {
|
||||||
render(<EngineControlMenu projectId={projectId} />);
|
render(
|
||||||
|
<ConfirmDialogProvider>
|
||||||
|
<EngineControlMenu projectId={projectId} />
|
||||||
|
</ConfirmDialogProvider>,
|
||||||
|
);
|
||||||
fireEvent.click(screen.getByTestId("engine-control-menu-trigger"));
|
fireEvent.click(screen.getByTestId("engine-control-menu-trigger"));
|
||||||
await screen.findByTestId("engine-control-menu");
|
await screen.findByTestId("engine-control-menu");
|
||||||
}
|
}
|
||||||
@@ -99,7 +104,7 @@ describe("EngineControlMenu", () => {
|
|||||||
await waitFor(() => expect(screen.queryByTestId("engine-control-menu")).not.toBeInTheDocument());
|
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();
|
await openMenu();
|
||||||
|
|
||||||
const maxConcurrent = await screen.findByLabelText(/max concurrent tasks/i);
|
const maxConcurrent = await screen.findByLabelText(/max concurrent tasks/i);
|
||||||
@@ -108,14 +113,11 @@ describe("EngineControlMenu", () => {
|
|||||||
fireEvent.change(maxConcurrent, { target: { value: "7" } });
|
fireEvent.change(maxConcurrent, { target: { value: "7" } });
|
||||||
fireEvent.click(screen.getByTestId("engine-control-menu-close"));
|
fireEvent.click(screen.getByTestId("engine-control-menu-close"));
|
||||||
|
|
||||||
expect(legacyMocks.updateSettings).toHaveBeenCalledWith(
|
|
||||||
{ maxConcurrent: 7, maxTriageConcurrent: 1, maxWorktrees: 4 },
|
|
||||||
"proj_123",
|
|
||||||
);
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
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 () => {
|
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 });
|
apiMocks.fetchSettings.mockResolvedValue({ ...defaultSettings, globalPause: true, enginePaused: true });
|
||||||
legacyMocks.fetchConfig.mockResolvedValue({ maxConcurrent: 2, rootDir: "/workspace/project" });
|
legacyMocks.fetchConfig.mockResolvedValue({ maxConcurrent: 2, rootDir: "/workspace/project" });
|
||||||
legacyMocks.fetchSettings.mockResolvedValue({ ...defaultSettings });
|
legacyMocks.fetchSettings.mockResolvedValue({ ...defaultSettings });
|
||||||
render(<EngineControlMenu projectId="proj_123" />);
|
render(
|
||||||
|
<ConfirmDialogProvider>
|
||||||
|
<EngineControlMenu projectId="proj_123" />
|
||||||
|
</ConfirmDialogProvider>,
|
||||||
|
);
|
||||||
fireEvent.click(screen.getAllByTestId("engine-control-menu-trigger")[1]);
|
fireEvent.click(screen.getAllByTestId("engine-control-menu-trigger")[1]);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getAllByTestId("engine-control-pause-triage-btn")).toHaveLength(2));
|
await waitFor(() => expect(screen.getAllByTestId("engine-control-pause-triage-btn")).toHaveLength(2));
|
||||||
@@ -173,7 +179,58 @@ describe("EngineControlMenu", () => {
|
|||||||
expect(pauseButton).toHaveTextContent(/resume scheduling/i);
|
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({
|
legacyMocks.fetchSettings.mockResolvedValue({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
maxConcurrent: 60,
|
maxConcurrent: 60,
|
||||||
@@ -203,10 +260,20 @@ describe("EngineControlMenu", () => {
|
|||||||
await vi.advanceTimersByTimeAsync(500);
|
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 },
|
{ maxConcurrent: 9, maxTriageConcurrent: 4, maxWorktrees: 8 },
|
||||||
"proj_123",
|
"proj_123",
|
||||||
);
|
));
|
||||||
expect(apiMocks.fetchSettings).toHaveBeenCalledTimes(2);
|
expect(apiMocks.fetchSettings).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,6 +291,194 @@ describe("EngineControlMenu", () => {
|
|||||||
expect(screen.getByLabelText(/max worktrees/i)).toHaveAttribute("max", "50");
|
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<string, number>;
|
||||||
|
}) => 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 () => {
|
it("renders running counts and current-use markers with clamped absolute utilization", async () => {
|
||||||
legacyMocks.fetchSettings.mockResolvedValue({
|
legacyMocks.fetchSettings.mockResolvedValue({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
@@ -357,7 +612,7 @@ describe("EngineControlMenu", () => {
|
|||||||
expect(screen.queryByTestId("engine-control-project-use-marker")).not.toBeInTheDocument();
|
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();
|
await openMenu();
|
||||||
|
|
||||||
const maxConcurrent = await screen.findByLabelText(/max concurrent tasks/i);
|
const maxConcurrent = await screen.findByLabelText(/max concurrent tasks/i);
|
||||||
@@ -370,11 +625,13 @@ describe("EngineControlMenu", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
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 },
|
{ maxConcurrent: 50, maxTriageConcurrent: 1, maxWorktrees: 4 },
|
||||||
"proj_123",
|
"proj_123",
|
||||||
);
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a load error state without crashing", async () => {
|
it("renders a load error state without crashing", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user