FN-7084: add concurrency save confirmations
Require explicit confirmation before Command Center concurrency sliders persist live capacity changes. - Add confirmation flows for global and project concurrency sliders after debounce settles. - Revert pending slider values on cancel, Escape, or backdrop dismissal without saving. - Cover single, batched, no-op, failure, and dismissal paths in Command Center control tests. - Document the confirmation behavior and add a published package changeset. Files changed: .changeset/FN-7084-concurrency-confirm.md | 7 + docs/dashboard-guide.md | 3 +- .../command-center/CommandCenterControls.tsx | 169 ++++++++++++++--- .../__tests__/CommandCenterControls.test.tsx | 203 +++++++++++++++++---- 4 files changed, 323 insertions(+), 59 deletions(-) Fusion-Task-Id: FN-7084 Fusion-Task-Lineage: f1ebe1f7-f570-4bcb-9dad-7c8f03808d54 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/FN-7084-concurrency-confirm.md
Normal file
7
.changeset/FN-7084-concurrency-confirm.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Add confirmation prompts before Command Center concurrency sliders save live capacity changes.
|
||||||
|
category: feature
|
||||||
|
dev: Command Center global and project concurrency sliders now confirm changed settled values before persisting.
|
||||||
@@ -840,7 +840,8 @@ Features:
|
|||||||
<!-- FNXC:CommandCenter 2026-06-25-19:47: FN-7019 restored the user-facing picker contract: preset and custom date-range selections must change every historical analytics tab, while Mission Control stays live and intentionally ignores historical range filters. -->
|
<!-- FNXC:CommandCenter 2026-06-25-19:47: FN-7019 restored the user-facing picker contract: preset and custom date-range selections must change every historical analytics tab, while Mission Control stays live and intentionally ignores historical range filters. -->
|
||||||
<!-- FNXC:CommandCenter 2026-06-19-23:54: FN-6755 moved team-specific operations out of Overview: org hierarchy and heartbeat pause/resume live in Team, while Overview keeps global AI engine, concurrency, and theme controls. -->
|
<!-- FNXC:CommandCenter 2026-06-19-23:54: FN-6755 moved team-specific operations out of Overview: org hierarchy and heartbeat pause/resume live in Team, while Overview keeps global AI engine, concurrency, and theme controls. -->
|
||||||
<!-- FNXC:GlobalConcurrencyControls 2026-06-26-00:00: The Command Center Concurrency card mirrors the footer concurrency popover by showing read-only running-agent counts and current-use markers for the shared global cap and current-project max-concurrent slider. -->
|
<!-- FNXC:GlobalConcurrencyControls 2026-06-26-00:00: The Command Center Concurrency card mirrors the footer concurrency popover by showing read-only running-agent counts and current-use markers for the shared global cap and current-project max-concurrent slider. -->
|
||||||
- **Overview controls dashboard** sits at the top of the Overview landing surface on desktop and mobile. It includes AI engine stop/start backed by `globalPause`, live scheduler status from executor stats, the shared Global Max Concurrent slider backed by `/api/global-concurrency`, range sliders for `maxConcurrent`, `maxTriageConcurrent`, and `maxWorktrees` that persist through `/api/settings`, and a compact theme dropdown with the same color-chip swatches and Shadcn variant list as Settings → Appearance. The global and current-project max-concurrent sliders show running-agent counts plus a current-use dot on the track once utilization data loads; triage and worktree sliders remain cap-only. These controls reuse existing APIs and App-level theme setters; they do not add a new backend route or second theme owner.
|
<!-- FNXC:CommandCenter 2026-06-26-00:00: The four Overview Concurrency sliders change live scheduler capacity, so each settled edit opens a confirmation popup before persisting; cancel, backdrop, or Escape leaves the previous persisted value in place. -->
|
||||||
|
- **Overview controls dashboard** sits at the top of the Overview landing surface on desktop and mobile. It includes AI engine stop/start backed by `globalPause`, live scheduler status from executor stats, the shared Global Max Concurrent slider backed by `/api/global-concurrency`, range sliders for `maxConcurrent`, `maxTriageConcurrent`, and `maxWorktrees` that persist through `/api/settings`, and a compact theme dropdown with the same color-chip swatches and Shadcn variant list as Settings → Appearance. The four concurrency sliders ask for confirmation after a changed value settles; confirming persists the new cap, while cancel, backdrop, or Escape dismissal reverts to the last persisted value without saving. The global and current-project max-concurrent sliders show running-agent counts plus a current-use dot on the track once utilization data loads; triage and worktree sliders remain cap-only. These controls reuse existing APIs and App-level theme setters; they do not add a new backend route or second theme owner.
|
||||||
- **Overview** summarizes token usage/cost, autonomy, active nodes, sessions, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range at the bottom of the Overview content in loading, error, empty, and populated states. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The sessions card uses the selected-range `ActivityAnalytics.sessions` value already loaded for the overview. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes a graph-rich software-factory snapshot with the existing tokens-by-model bar, tool-category bar, real recharts token-share pie, and the daily activity multi-series line chart placed before the daily activity sparkline/trend so the richer line graph sits higher in the chart grid. These reuse the already-loaded tokens, tools, activity, and signals analytics; the signals count comes from `/api/command-center/signals` and renders unavailable (`—`) while the incidents-backed response is loading or unavailable. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range.
|
- **Overview** summarizes token usage/cost, autonomy, active nodes, sessions, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range at the bottom of the Overview content in loading, error, empty, and populated states. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The sessions card uses the selected-range `ActivityAnalytics.sessions` value already loaded for the overview. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes a graph-rich software-factory snapshot with the existing tokens-by-model bar, tool-category bar, real recharts token-share pie, and the daily activity multi-series line chart placed before the daily activity sparkline/trend so the richer line graph sits higher in the chart grid. These reuse the already-loaded tokens, tools, activity, and signals analytics; the signals count comes from `/api/command-center/signals` and renders unavailable (`—`) while the incidents-backed response is loading or unavailable. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range.
|
||||||
<!-- FNXC:CommandCenter 2026-06-21-00:00: Command Center cost must read as an estimated, derived value from recorded token counts and the hand-maintained model pricing map; it is never persisted, and the UI must surface prices-as-of, stale low-confidence, and unavailable unknown-model states instead of implying billing truth. -->
|
<!-- FNXC:CommandCenter 2026-06-21-00:00: Command Center cost must read as an estimated, derived value from recorded token counts and the hand-maintained model pricing map; it is never persisted, and the UI must surface prices-as-of, stale low-confidence, and unavailable unknown-model states instead of implying billing truth. -->
|
||||||
<!-- FNXC:CommandCenter 2026-06-22-00:00: FN-6876 requires user-maintained/LiteLLM-fetched pricing overrides to feed Tokens and Team estimates immediately without implying provider billing reconciliation. -->
|
<!-- FNXC:CommandCenter 2026-06-22-00:00: FN-6876 requires user-maintained/LiteLLM-fetched pricing overrides to feed Tokens and Team estimates immediately without implying provider billing reconciliation. -->
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useState, type CSSProperties } from "react";
|
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Power } from "lucide-react";
|
import { Power } from "lucide-react";
|
||||||
import { DEFAULT_PROJECT_SETTINGS, type ColorTheme, type ThemeMode } from "@fusion/core";
|
import { DEFAULT_PROJECT_SETTINGS, type ColorTheme, type ThemeMode } from "@fusion/core";
|
||||||
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: Concurrency card adopts the shared global-concurrency hook so it and the footer EngineControlMenu read/write ONE source of truth (no more duplicated fetch/debounce/clobber logic).
|
// FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Concurrency card adopts the shared global-concurrency hook so it and the footer EngineControlMenu read/write ONE source of truth (no more duplicated fetch/debounce/clobber logic).
|
||||||
import { useGlobalConcurrency } from "../../hooks/useGlobalConcurrency";
|
import { useGlobalConcurrency } from "../../hooks/useGlobalConcurrency";
|
||||||
import { ThemeDropdown } from "../ThemeDropdown";
|
import { ThemeDropdown } from "../ThemeDropdown";
|
||||||
@@ -47,6 +48,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" },
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:CommandCenter 2026-06-21-00:00:
|
FNXC:CommandCenter 2026-06-21-00:00:
|
||||||
Operator concurrency sliders must allow dragging each scheduler capacity control up to 50 by default while still expanding beyond 50 for already-persisted higher values so FN-6768 truthful readouts remain intact.
|
Operator concurrency sliders must allow dragging each scheduler capacity control up to 50 by default while still expanding beyond 50 for already-persisted higher values so FN-6768 truthful readouts remain intact.
|
||||||
@@ -74,6 +81,10 @@ function getUseMarkerStyle(ratio: number): CSSProperties {
|
|||||||
} as CSSProperties;
|
} as CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getChangedConcurrencyKeys(values: ConcurrencyValues, persisted: ConcurrencyValues) {
|
||||||
|
return (Object.keys(values) as Array<keyof ConcurrencyValues>).filter((key) => values[key] !== persisted[key]);
|
||||||
|
}
|
||||||
|
|
||||||
function StatusPill({ paused, label }: { paused: boolean; label: string }) {
|
function StatusPill({ paused, label }: { paused: boolean; label: string }) {
|
||||||
return (
|
return (
|
||||||
<span className="cc-controls-status-pill">
|
<span className="cc-controls-status-pill">
|
||||||
@@ -85,6 +96,7 @@ function StatusPill({ paused, label }: { paused: boolean; label: string }) {
|
|||||||
|
|
||||||
export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcnCustomColors = {}, resolvedThemeMode = themeMode === "light" ? "light" : "dark", onColorThemeChange, onThemeModeChange, onShadcnCustomColorsChange = () => {}, onChangeView }: CommandCenterControlsProps) {
|
export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcnCustomColors = {}, resolvedThemeMode = themeMode === "light" ? "light" : "dark", onColorThemeChange, onThemeModeChange, onShadcnCustomColorsChange = () => {}, onChangeView }: CommandCenterControlsProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
|
const { confirm } = useConfirm();
|
||||||
const {
|
const {
|
||||||
globalPaused,
|
globalPaused,
|
||||||
toggleGlobalPause,
|
toggleGlobalPause,
|
||||||
@@ -93,6 +105,12 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
|
|||||||
const [concurrencyState, setConcurrencyState] = useState<AsyncState<ConcurrencyValues>>({ status: "loading", data: null, error: null });
|
const [concurrencyState, setConcurrencyState] = useState<AsyncState<ConcurrencyValues>>({ status: "loading", 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 persistedConcurrencyRef = useRef<ConcurrencyValues>(DEFAULT_CONCURRENCY_VALUES);
|
||||||
|
const pendingConcurrencyKeyRef = useRef<keyof ConcurrencyValues | null>(null);
|
||||||
|
const concurrencyConfirmOpenRef = useRef(false);
|
||||||
|
const [pendingGlobalConcurrencyValue, setPendingGlobalConcurrencyValue] = useState<number | null>(null);
|
||||||
|
const [globalConcurrencyDirty, setGlobalConcurrencyDirty] = useState(false);
|
||||||
|
const globalConcurrencyConfirmOpenRef = useRef(false);
|
||||||
// FNXC:GlobalConcurrencyControls 2026-06-25-22:45: No activeWhen — the card is mounted only while visible, so it fetches on mount and flushes pending writes on unmount via the shared hook.
|
// FNXC:GlobalConcurrencyControls 2026-06-25-22:45: No activeWhen — the card is mounted only while visible, so it fetches on mount and flushes pending writes on unmount via the shared hook.
|
||||||
const gc = useGlobalConcurrency();
|
const gc = useGlobalConcurrency();
|
||||||
|
|
||||||
@@ -105,13 +123,17 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
|
|||||||
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,
|
||||||
|
};
|
||||||
|
persistedConcurrencyRef.current = persistedValues;
|
||||||
|
pendingConcurrencyKeyRef.current = null;
|
||||||
|
concurrencyConfirmOpenRef.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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -130,26 +152,80 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
|
|||||||
};
|
};
|
||||||
}, [projectId, t]);
|
}, [projectId, t]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CommandCenter 2026-06-26-00:00:
|
||||||
|
Concurrency edits mutate live scheduler capacity, so the card must ask for explicit operator confirmation after a slider settles. The UI still updates optimistically while dragging, but cancel, close, backdrop, and Escape all revert to the last persisted values without calling updateSettings.
|
||||||
|
|
||||||
|
FNXC:CommandCenter 2026-06-26-18:08:
|
||||||
|
If multiple per-project sliders change inside one debounce window, the confirmation must name every changed scheduler setting before saving the combined update so no capacity change persists silently under another slider's dialog.
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!concurrencyDirty || !concurrencyState.data) return;
|
if (!concurrencyDirty || !concurrencyState.data || concurrencyConfirmOpenRef.current) return;
|
||||||
const values = concurrencyState.data;
|
const values = concurrencyState.data;
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
setConcurrencySaveState("saving");
|
const persisted = persistedConcurrencyRef.current;
|
||||||
void updateSettings(values, projectId)
|
const changedKeys = getChangedConcurrencyKeys(values, persisted);
|
||||||
.then(async () => {
|
if (changedKeys.length === 0) {
|
||||||
await refresh();
|
setConcurrencyDirty(false);
|
||||||
|
pendingConcurrencyKeyRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
concurrencyConfirmOpenRef.current = true;
|
||||||
|
const changeSummary = changedKeys.map((key) => {
|
||||||
|
const labelMeta = CONCURRENCY_SETTING_LABEL_KEYS[key];
|
||||||
|
return t(
|
||||||
|
"commandCenter.controls.concurrency.confirmChangeSummaryItem",
|
||||||
|
"{{setting}} from {{oldValue}} to {{newValue}}",
|
||||||
|
{ setting: t(labelMeta.key, labelMeta.defaultValue), oldValue: persisted[key], newValue: 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) => {
|
||||||
|
concurrencyConfirmOpenRef.current = false;
|
||||||
|
if (!confirmed) {
|
||||||
|
setConcurrencyState({ status: "loaded", data: persistedConcurrencyRef.current, error: null });
|
||||||
setConcurrencyDirty(false);
|
setConcurrencyDirty(false);
|
||||||
setConcurrencySaveState("saved");
|
pendingConcurrencyKeyRef.current = null;
|
||||||
})
|
setConcurrencySaveState("idle");
|
||||||
.catch(() => {
|
return;
|
||||||
setConcurrencySaveState("error");
|
}
|
||||||
});
|
|
||||||
|
setConcurrencySaveState("saving");
|
||||||
|
void updateSettings(values, projectId)
|
||||||
|
.then(async () => {
|
||||||
|
await refresh();
|
||||||
|
persistedConcurrencyRef.current = values;
|
||||||
|
setConcurrencyDirty(false);
|
||||||
|
pendingConcurrencyKeyRef.current = null;
|
||||||
|
setConcurrencySaveState("saved");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setConcurrencySaveState("error");
|
||||||
|
});
|
||||||
|
});
|
||||||
}, CONCURRENCY_SAVE_DEBOUNCE_MS);
|
}, CONCURRENCY_SAVE_DEBOUNCE_MS);
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [concurrencyDirty, concurrencyState.data, projectId, refresh]);
|
}, [confirm, concurrencyDirty, concurrencyState.data, projectId, refresh, t]);
|
||||||
|
|
||||||
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);
|
||||||
|
pendingConcurrencyKeyRef.current = key;
|
||||||
setConcurrencyState((current) => ({
|
setConcurrencyState((current) => ({
|
||||||
status: "loaded",
|
status: "loaded",
|
||||||
data: { ...(current.data ?? DEFAULT_CONCURRENCY_VALUES), [key]: nextValue },
|
data: { ...(current.data ?? DEFAULT_CONCURRENCY_VALUES), [key]: nextValue },
|
||||||
@@ -159,12 +235,61 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
|
|||||||
setConcurrencySaveState("idle");
|
setConcurrencySaveState("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateGlobalConcurrencyValue = (rawValue: string) => {
|
||||||
|
const nextValue = clamp(Number(rawValue), gc.min, gc.sliderMax);
|
||||||
|
setPendingGlobalConcurrencyValue(nextValue);
|
||||||
|
setGlobalConcurrencyDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CommandCenter 2026-06-26-00:00:
|
||||||
|
The Command Center global-cap slider shares useGlobalConcurrency with the footer EngineControlMenu, so confirmation is card-local: drag into pending state, confirm once after settle, then call gc.setValue exactly once so the hook's existing debounce and footer behavior remain unchanged.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!globalConcurrencyDirty || pendingGlobalConcurrencyValue === null || !gc.interactive || globalConcurrencyConfirmOpenRef.current) return;
|
||||||
|
const nextValue = pendingGlobalConcurrencyValue;
|
||||||
|
const persistedValue = gc.value;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (nextValue === persistedValue) {
|
||||||
|
setPendingGlobalConcurrencyValue(null);
|
||||||
|
setGlobalConcurrencyDirty(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalConcurrencyConfirmOpenRef.current = true;
|
||||||
|
void confirm({
|
||||||
|
title: t("commandCenter.controls.concurrency.confirmTitle", "Confirm concurrency change"),
|
||||||
|
message: t(
|
||||||
|
"commandCenter.controls.concurrency.confirmMessage",
|
||||||
|
"Change {{setting}} from {{oldValue}} to {{newValue}}?",
|
||||||
|
{
|
||||||
|
setting: t("settings.scheduling.globalMaxConcurrent", "Global Max Concurrent"),
|
||||||
|
oldValue: persistedValue,
|
||||||
|
newValue: nextValue,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
confirmLabel: t("commandCenter.controls.concurrency.confirmSave", "Save change"),
|
||||||
|
cancelLabel: t("commandCenter.controls.concurrency.confirmCancel", "Cancel"),
|
||||||
|
}).then((confirmed) => {
|
||||||
|
globalConcurrencyConfirmOpenRef.current = false;
|
||||||
|
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, pendingGlobalConcurrencyValue, t]);
|
||||||
|
|
||||||
const effectiveGlobalPaused = globalPaused;
|
const effectiveGlobalPaused = globalPaused;
|
||||||
const concurrencyValues = concurrencyState.data ?? DEFAULT_CONCURRENCY_VALUES;
|
const concurrencyValues = concurrencyState.data ?? DEFAULT_CONCURRENCY_VALUES;
|
||||||
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.min, gc.sliderMax);
|
const globalUseMarkerRatio = getUseMarkerRatio(gc.currentlyActive, gc.min, globalSliderMax);
|
||||||
const projectUseMarkerRatio = getUseMarkerRatio(projectActive, CONCURRENCY_SLIDER_LIMITS.maxConcurrent.min, maxConcurrentSliderMax);
|
const projectUseMarkerRatio = getUseMarkerRatio(projectActive, CONCURRENCY_SLIDER_LIMITS.maxConcurrent.min, maxConcurrentSliderMax);
|
||||||
// FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Mirror the per-project slider save-state labels for the shared global cap.
|
// FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Mirror the per-project slider save-state labels for the shared global cap.
|
||||||
// FNXC:GlobalConcurrencyControls 2026-06-26-06:05: Explicit load-error branch — a failed initial load leaves saveState "idle", so the label otherwise fell through to "Ready" while the slider was disabled and an error alert shown.
|
// FNXC:GlobalConcurrencyControls 2026-06-26-06:05: Explicit load-error branch — a failed initial load leaves saveState "idle", so the label otherwise fell through to "Ready" while the slider was disabled and an error alert shown.
|
||||||
@@ -284,7 +409,7 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
|
|||||||
<label className="cc-controls-slider cc-controls-slider--global" htmlFor="cc-global-max-concurrent">
|
<label className="cc-controls-slider cc-controls-slider--global" htmlFor="cc-global-max-concurrent">
|
||||||
<span className="cc-controls-slider-label">
|
<span className="cc-controls-slider-label">
|
||||||
{t("settings.scheduling.globalMaxConcurrent", "Global Max Concurrent")}
|
{t("settings.scheduling.globalMaxConcurrent", "Global Max Concurrent")}
|
||||||
<strong>{gc.value}</strong>
|
<strong>{globalSliderValue}</strong>
|
||||||
</span>
|
</span>
|
||||||
<small className="cc-controls-slider-caption">{t("settings.scheduling.maximumConcurrentAgentsAcrossAllProjects", "Maximum concurrent agents across all projects")}</small>
|
<small className="cc-controls-slider-caption">{t("settings.scheduling.maximumConcurrentAgentsAcrossAllProjects", "Maximum concurrent agents across all projects")}</small>
|
||||||
{globalCountsLoaded ? (
|
{globalCountsLoaded ? (
|
||||||
@@ -298,10 +423,10 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
|
|||||||
className="cc-controls-touch-slider"
|
className="cc-controls-touch-slider"
|
||||||
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}
|
||||||
onChange={(event) => gc.setValue(event.target.value)}
|
onChange={(event) => updateGlobalConcurrencyValue(event.target.value)}
|
||||||
/>
|
/>
|
||||||
{globalCountsLoaded ? (
|
{globalCountsLoaded ? (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
import { CommandCenterControls } from "../CommandCenterControls";
|
import { CommandCenterControls } from "../CommandCenterControls";
|
||||||
import { COLOR_THEMES } from "../../themeOptions";
|
import { COLOR_THEMES } from "../../themeOptions";
|
||||||
|
import { ConfirmDialogProvider } from "../../../hooks/useConfirm";
|
||||||
|
|
||||||
const commandCenterControlsCss = readFileSync(
|
const commandCenterControlsCss = readFileSync(
|
||||||
join(process.cwd(), "app/components/command-center/CommandCenterControls.css"),
|
join(process.cwd(), "app/components/command-center/CommandCenterControls.css"),
|
||||||
@@ -45,13 +46,15 @@ vi.mock("../../../hooks/useAppSettings", () => ({
|
|||||||
|
|
||||||
function renderControls(projectId?: string) {
|
function renderControls(projectId?: string) {
|
||||||
return render(
|
return render(
|
||||||
<CommandCenterControls
|
<ConfirmDialogProvider>
|
||||||
projectId={projectId}
|
<CommandCenterControls
|
||||||
colorTheme="default"
|
projectId={projectId}
|
||||||
themeMode="dark"
|
colorTheme="default"
|
||||||
onColorThemeChange={vi.fn()}
|
themeMode="dark"
|
||||||
onThemeModeChange={vi.fn()}
|
onColorThemeChange={vi.fn()}
|
||||||
/>,
|
onThemeModeChange={vi.fn()}
|
||||||
|
/>
|
||||||
|
</ConfirmDialogProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +64,27 @@ async function flushPromises() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function advanceConfirmDebounce() {
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfirmDialog() {
|
||||||
|
return screen.getByRole("dialog", { name: /confirm concurrency change/i });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickConfirmSave() {
|
||||||
|
fireEvent.click(within(getConfirmDialog()).getByRole("button", { name: /save change/i }));
|
||||||
|
await flushPromises();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickConfirmCancel() {
|
||||||
|
fireEvent.click(within(getConfirmDialog()).getByRole("button", { name: /cancel/i }));
|
||||||
|
await flushPromises();
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -185,44 +209,91 @@ describe("CommandCenterControls", () => {
|
|||||||
expect(within(section).queryByTestId("cc-project-use-marker")).toBeNull();
|
expect(within(section).queryByTestId("cc-project-use-marker")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists shared global cap slider changes without mutating project settings", async () => {
|
it("gates the global cap slider behind confirmation before persisting", async () => {
|
||||||
renderControls("project-a");
|
renderControls("project-a");
|
||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
const section = screen.getByTestId("cc-controls-concurrency");
|
const section = screen.getByTestId("cc-controls-concurrency");
|
||||||
const slider = within(section).getByLabelText(/global max concurrent/i);
|
const slider = within(section).getByLabelText(/global max concurrent/i) as HTMLInputElement;
|
||||||
fireEvent.change(slider, { target: { value: "10" } });
|
fireEvent.change(slider, { target: { value: "10" } });
|
||||||
|
|
||||||
await act(async () => {
|
await advanceConfirmDebounce();
|
||||||
vi.advanceTimersByTime(500);
|
|
||||||
await Promise.resolve();
|
expect(getConfirmDialog()).toHaveTextContent("Change Global Max Concurrent from 8 to 10?");
|
||||||
});
|
expect(mocks.updateGlobalConcurrency).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.updateSettings).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await clickConfirmSave();
|
||||||
|
await advanceConfirmDebounce();
|
||||||
|
|
||||||
expect(mocks.updateGlobalConcurrency).toHaveBeenCalledWith({ globalMaxConcurrent: 10 });
|
expect(mocks.updateGlobalConcurrency).toHaveBeenCalledWith({ globalMaxConcurrent: 10 });
|
||||||
|
expect(mocks.updateGlobalConcurrency).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.updateSettings).not.toHaveBeenCalled();
|
expect(mocks.updateSettings).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists bounded concurrency slider changes and refreshes settings", async () => {
|
it.each([
|
||||||
|
{
|
||||||
|
name: "Max concurrent tasks",
|
||||||
|
label: /max concurrent tasks/i,
|
||||||
|
value: "7",
|
||||||
|
expected: { maxConcurrent: 7, maxTriageConcurrent: 2, maxWorktrees: 4 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Max triage concurrent",
|
||||||
|
label: /max triage concurrent/i,
|
||||||
|
value: "6",
|
||||||
|
expected: { maxConcurrent: 2, maxTriageConcurrent: 6, maxWorktrees: 4 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Max worktrees",
|
||||||
|
label: /max worktrees/i,
|
||||||
|
value: "12",
|
||||||
|
expected: { maxConcurrent: 2, maxTriageConcurrent: 2, maxWorktrees: 12 },
|
||||||
|
},
|
||||||
|
])("gates the $name slider behind confirmation before persisting", async ({ name, label, value, expected }) => {
|
||||||
renderControls("project-a");
|
renderControls("project-a");
|
||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
const section = screen.getByTestId("cc-controls-concurrency");
|
const section = screen.getByTestId("cc-controls-concurrency");
|
||||||
const slider = within(section).getByLabelText(/max concurrent tasks/i);
|
const slider = within(section).getByLabelText(label) as HTMLInputElement;
|
||||||
fireEvent.change(slider, { target: { value: "7" } });
|
fireEvent.change(slider, { target: { value } });
|
||||||
|
|
||||||
await act(async () => {
|
await advanceConfirmDebounce();
|
||||||
vi.advanceTimersByTime(500);
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mocks.updateSettings).toHaveBeenCalledWith(
|
expect(getConfirmDialog()).toHaveTextContent(`Change ${name} from ${name === "Max worktrees" ? 4 : 2} to ${value}?`);
|
||||||
{ maxConcurrent: 7, maxTriageConcurrent: 2, maxWorktrees: 4 },
|
expect(mocks.updateSettings).not.toHaveBeenCalled();
|
||||||
"project-a",
|
|
||||||
);
|
await clickConfirmSave();
|
||||||
|
|
||||||
|
expect(mocks.updateSettings).toHaveBeenCalledWith(expected, "project-a");
|
||||||
expect(mocks.refresh).toHaveBeenCalledTimes(1);
|
expect(mocks.refresh).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists concurrency slider changes at the default maximum of 50", async () => {
|
it("confirms all per-project slider changes made inside one debounce before persisting", async () => {
|
||||||
|
renderControls("project-a");
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
const section = screen.getByTestId("cc-controls-concurrency");
|
||||||
|
fireEvent.change(within(section).getByLabelText(/max concurrent tasks/i), { target: { value: "7" } });
|
||||||
|
fireEvent.change(within(section).getByLabelText(/max worktrees/i), { target: { value: "12" } });
|
||||||
|
|
||||||
|
await advanceConfirmDebounce();
|
||||||
|
|
||||||
|
const dialog = getConfirmDialog();
|
||||||
|
expect(dialog).toHaveTextContent("Change these concurrency settings");
|
||||||
|
expect(dialog).toHaveTextContent("Max concurrent tasks from 2 to 7");
|
||||||
|
expect(dialog).toHaveTextContent("Max worktrees from 4 to 12");
|
||||||
|
expect(mocks.updateSettings).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await clickConfirmSave();
|
||||||
|
|
||||||
|
expect(mocks.updateSettings).toHaveBeenCalledWith(
|
||||||
|
{ maxConcurrent: 7, maxTriageConcurrent: 2, maxWorktrees: 12 },
|
||||||
|
"project-a",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists concurrency slider changes at the default maximum of 50 after confirmation", async () => {
|
||||||
renderControls("project-a");
|
renderControls("project-a");
|
||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
@@ -230,10 +301,8 @@ describe("CommandCenterControls", () => {
|
|||||||
const slider = within(section).getByLabelText(/max concurrent tasks/i);
|
const slider = within(section).getByLabelText(/max concurrent tasks/i);
|
||||||
fireEvent.change(slider, { target: { value: "50" } });
|
fireEvent.change(slider, { target: { value: "50" } });
|
||||||
|
|
||||||
await act(async () => {
|
await advanceConfirmDebounce();
|
||||||
vi.advanceTimersByTime(500);
|
await clickConfirmSave();
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mocks.updateSettings).toHaveBeenCalledWith(
|
expect(mocks.updateSettings).toHaveBeenCalledWith(
|
||||||
{ maxConcurrent: 50, maxTriageConcurrent: 2, maxWorktrees: 4 },
|
{ maxConcurrent: 50, maxTriageConcurrent: 2, maxWorktrees: 4 },
|
||||||
@@ -241,7 +310,7 @@ describe("CommandCenterControls", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists concurrency slider changes without a project id", async () => {
|
it("persists concurrency slider changes without a project id after confirmation", async () => {
|
||||||
renderControls(undefined);
|
renderControls(undefined);
|
||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
@@ -249,10 +318,8 @@ describe("CommandCenterControls", () => {
|
|||||||
const slider = within(section).getByLabelText(/max worktrees/i);
|
const slider = within(section).getByLabelText(/max worktrees/i);
|
||||||
fireEvent.change(slider, { target: { value: "12" } });
|
fireEvent.change(slider, { target: { value: "12" } });
|
||||||
|
|
||||||
await act(async () => {
|
await advanceConfirmDebounce();
|
||||||
vi.advanceTimersByTime(500);
|
await clickConfirmSave();
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mocks.updateSettings).toHaveBeenCalledWith(
|
expect(mocks.updateSettings).toHaveBeenCalledWith(
|
||||||
{ maxConcurrent: 2, maxTriageConcurrent: 2, maxWorktrees: 12 },
|
{ maxConcurrent: 2, maxTriageConcurrent: 2, maxWorktrees: 12 },
|
||||||
@@ -260,6 +327,71 @@ describe("CommandCenterControls", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("cancels per-project concurrency changes and reverts the slider without saving", async () => {
|
||||||
|
renderControls("project-a");
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
const section = screen.getByTestId("cc-controls-concurrency");
|
||||||
|
const slider = within(section).getByLabelText(/max concurrent tasks/i) as HTMLInputElement;
|
||||||
|
fireEvent.change(slider, { target: { value: "9" } });
|
||||||
|
expect(slider.value).toBe("9");
|
||||||
|
|
||||||
|
await advanceConfirmDebounce();
|
||||||
|
await clickConfirmCancel();
|
||||||
|
|
||||||
|
expect(slider.value).toBe("2");
|
||||||
|
expect(mocks.updateSettings).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismisses per-project concurrency changes with Escape and reverts without saving", async () => {
|
||||||
|
renderControls("project-a");
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
const section = screen.getByTestId("cc-controls-concurrency");
|
||||||
|
const slider = within(section).getByLabelText(/max triage concurrent/i) as HTMLInputElement;
|
||||||
|
fireEvent.change(slider, { target: { value: "5" } });
|
||||||
|
|
||||||
|
await advanceConfirmDebounce();
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(slider.value).toBe("2");
|
||||||
|
expect(mocks.updateSettings).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismisses global concurrency changes via backdrop and reverts without saving", async () => {
|
||||||
|
renderControls("project-a");
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
const section = screen.getByTestId("cc-controls-concurrency");
|
||||||
|
const slider = within(section).getByLabelText(/global max concurrent/i) as HTMLInputElement;
|
||||||
|
fireEvent.change(slider, { target: { value: "11" } });
|
||||||
|
expect(slider.value).toBe("11");
|
||||||
|
|
||||||
|
await advanceConfirmDebounce();
|
||||||
|
fireEvent.click(getConfirmDialog().parentElement!);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(slider.value).toBe("8");
|
||||||
|
await advanceConfirmDebounce();
|
||||||
|
expect(mocks.updateGlobalConcurrency).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open a confirmation or save for no-op slider settles", async () => {
|
||||||
|
renderControls("project-a");
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
const section = screen.getByTestId("cc-controls-concurrency");
|
||||||
|
fireEvent.change(within(section).getByLabelText(/global max concurrent/i), { target: { value: "8" } });
|
||||||
|
fireEvent.change(within(section).getByLabelText(/max worktrees/i), { target: { value: "4" } });
|
||||||
|
|
||||||
|
await advanceConfirmDebounce();
|
||||||
|
|
||||||
|
expect(screen.queryByRole("dialog", { name: /confirm concurrency change/i })).toBeNull();
|
||||||
|
expect(mocks.updateGlobalConcurrency).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.updateSettings).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders persisted concurrency settings without stale default drift", async () => {
|
it("renders persisted concurrency settings without stale default drift", async () => {
|
||||||
mocks.fetchSettings.mockResolvedValueOnce({ maxConcurrent: 6, maxTriageConcurrent: 3, maxWorktrees: 9 });
|
mocks.fetchSettings.mockResolvedValueOnce({ maxConcurrent: 6, maxTriageConcurrent: 3, maxWorktrees: 9 });
|
||||||
|
|
||||||
@@ -339,6 +471,7 @@ describe("CommandCenterControls", () => {
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
const section = screen.getByTestId("cc-controls-concurrency");
|
const section = screen.getByTestId("cc-controls-concurrency");
|
||||||
const sliders = [
|
const sliders = [
|
||||||
|
within(section).getByLabelText(/global max concurrent/i),
|
||||||
within(section).getByLabelText(/max concurrent tasks/i),
|
within(section).getByLabelText(/max concurrent tasks/i),
|
||||||
within(section).getByLabelText(/max triage concurrent/i),
|
within(section).getByLabelText(/max triage concurrent/i),
|
||||||
within(section).getByLabelText(/max worktrees/i),
|
within(section).getByLabelText(/max worktrees/i),
|
||||||
@@ -365,10 +498,8 @@ describe("CommandCenterControls", () => {
|
|||||||
const slider = within(section).getByLabelText(/max concurrent tasks/i);
|
const slider = within(section).getByLabelText(/max concurrent tasks/i);
|
||||||
fireEvent.change(slider, { target: { value: "8" } });
|
fireEvent.change(slider, { target: { value: "8" } });
|
||||||
|
|
||||||
await act(async () => {
|
await advanceConfirmDebounce();
|
||||||
vi.advanceTimersByTime(500);
|
await clickConfirmSave();
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(within(section).getByText(/save failed/i)).toBeDefined();
|
expect(within(section).getByText(/save failed/i)).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user