From 0ddfe9a4224165687be4666024313dc4abd8daa9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 26 Jun 2026 18:01:21 -0700 Subject: [PATCH] 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) --- .changeset/FN-7084-concurrency-confirm.md | 7 + docs/dashboard-guide.md | 3 +- .../command-center/CommandCenterControls.tsx | 169 ++++++++++++-- .../__tests__/CommandCenterControls.test.tsx | 207 ++++++++++++++---- 4 files changed, 325 insertions(+), 61 deletions(-) create mode 100644 .changeset/FN-7084-concurrency-confirm.md diff --git a/.changeset/FN-7084-concurrency-confirm.md b/.changeset/FN-7084-concurrency-confirm.md new file mode 100644 index 0000000000..74fa27485a --- /dev/null +++ b/.changeset/FN-7084-concurrency-confirm.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 5f9387d877..58a695a389 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -840,7 +840,8 @@ Features: -- **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. + +- **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. diff --git a/packages/dashboard/app/components/command-center/CommandCenterControls.tsx b/packages/dashboard/app/components/command-center/CommandCenterControls.tsx index 982a08e730..005825fcd0 100644 --- a/packages/dashboard/app/components/command-center/CommandCenterControls.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenterControls.tsx @@ -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 { Power } from "lucide-react"; import { DEFAULT_PROJECT_SETTINGS, type ColorTheme, type ThemeMode } from "@fusion/core"; import { fetchConfig, fetchSettings, updateSettings } from "../../api/legacy"; 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). import { useGlobalConcurrency } from "../../hooks/useGlobalConcurrency"; import { ThemeDropdown } from "../ThemeDropdown"; @@ -47,6 +48,12 @@ const CONCURRENCY_SLIDER_LIMITS: Record = { + maxConcurrent: { key: "commandCenter.controls.concurrency.maxConcurrent", defaultValue: "Max concurrent tasks" }, + maxTriageConcurrent: { key: "commandCenter.controls.concurrency.maxTriageConcurrent", defaultValue: "Max triage concurrent" }, + maxWorktrees: { key: "commandCenter.controls.concurrency.maxWorktrees", defaultValue: "Max worktrees" }, +}; + /* 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. @@ -74,6 +81,10 @@ function getUseMarkerStyle(ratio: number): CSSProperties { } as CSSProperties; } +function getChangedConcurrencyKeys(values: ConcurrencyValues, persisted: ConcurrencyValues) { + return (Object.keys(values) as Array).filter((key) => values[key] !== persisted[key]); +} + function StatusPill({ paused, label }: { paused: boolean; label: string }) { return ( @@ -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) { const { t } = useTranslation("app"); + const { confirm } = useConfirm(); const { globalPaused, toggleGlobalPause, @@ -93,6 +105,12 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn const [concurrencyState, setConcurrencyState] = useState>({ status: "loading", data: null, error: null }); const [concurrencyDirty, setConcurrencyDirty] = useState(false); const [concurrencySaveState, setConcurrencySaveState] = useState<"idle" | "saving" | "saved" | "error">("idle"); + const persistedConcurrencyRef = useRef(DEFAULT_CONCURRENCY_VALUES); + const pendingConcurrencyKeyRef = useRef(null); + const concurrencyConfirmOpenRef = useRef(false); + const [pendingGlobalConcurrencyValue, setPendingGlobalConcurrencyValue] = useState(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. const gc = useGlobalConcurrency(); @@ -105,13 +123,17 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn try { const [config, settings] = await Promise.all([fetchConfig(projectId), fetchSettings(projectId)]); 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({ status: "loaded", - data: { - maxConcurrent: settings.maxConcurrent ?? config.maxConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxConcurrent, - maxTriageConcurrent: settings.maxTriageConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxTriageConcurrent, - maxWorktrees: settings.maxWorktrees ?? DEFAULT_CONCURRENCY_VALUES.maxWorktrees, - }, + data: persistedValues, error: null, }); } @@ -130,26 +152,80 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn }; }, [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(() => { - if (!concurrencyDirty || !concurrencyState.data) return; + if (!concurrencyDirty || !concurrencyState.data || concurrencyConfirmOpenRef.current) return; const values = concurrencyState.data; const timeoutId = setTimeout(() => { - setConcurrencySaveState("saving"); - void updateSettings(values, projectId) - .then(async () => { - await refresh(); + const persisted = persistedConcurrencyRef.current; + const changedKeys = getChangedConcurrencyKeys(values, persisted); + if (changedKeys.length === 0) { + 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); - setConcurrencySaveState("saved"); - }) - .catch(() => { - setConcurrencySaveState("error"); - }); + pendingConcurrencyKeyRef.current = null; + setConcurrencySaveState("idle"); + return; + } + + 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); 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 nextValue = clamp(Number(rawValue), min, max); + pendingConcurrencyKeyRef.current = key; setConcurrencyState((current) => ({ status: "loaded", data: { ...(current.data ?? DEFAULT_CONCURRENCY_VALUES), [key]: nextValue }, @@ -159,12 +235,61 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn 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 concurrencyValues = concurrencyState.data ?? DEFAULT_CONCURRENCY_VALUES; const globalCountsLoaded = gc.status === "loaded"; const projectActive = gc.projectActiveCount(projectId); + const globalSliderValue = pendingGlobalConcurrencyValue ?? gc.value; + const globalSliderMax = Math.max(gc.sliderMax, globalSliderValue); 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); // 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. @@ -284,7 +409,7 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn