import "./EngineControlMenu.css"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { DEFAULT_PROJECT_SETTINGS } from "@fusion/core"; import { Pause, Play, SlidersHorizontal, Square } from "lucide-react"; import { fetchConfig, fetchSettings, updateSettings } from "../api/legacy"; import { useAppSettings } from "../hooks/useAppSettings"; export interface EngineControlMenuHandle { open: () => void; close: () => void; toggle: () => void; } export interface EngineControlMenuProps { projectId?: string; } type AsyncState = | { status: "idle" | "loading"; data: T | null; error: null } | { status: "loaded"; data: T; error: null } | { status: "error"; data: T | null; error: string }; type ConcurrencyValues = { maxConcurrent: number; maxTriageConcurrent: number; maxWorktrees: number; }; const CONCURRENCY_SAVE_DEBOUNCE_MS = 500; const DEFAULT_CONCURRENCY_VALUES: ConcurrencyValues = { maxConcurrent: DEFAULT_PROJECT_SETTINGS.maxConcurrent, maxTriageConcurrent: DEFAULT_PROJECT_SETTINGS.maxTriageConcurrent, maxWorktrees: DEFAULT_PROJECT_SETTINGS.maxWorktrees, }; const CONCURRENCY_SLIDER_LIMITS: Record = { maxConcurrent: { min: 1, max: 10 }, maxTriageConcurrent: { min: 1, max: 10 }, maxWorktrees: { min: 1, max: 20 }, }; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function getConcurrencySliderMax(key: keyof ConcurrencyValues, value: number) { return Math.max(CONCURRENCY_SLIDER_LIMITS[key].max, value); } function getErrorMessage(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; } /* FNXC:EngineControls 2026-06-21-00:00: Engine stop/start, triage pause/resume, and live scheduler concurrency/worktree sliders moved from the Header split button into the footer status bar. Operators open this popover from the footer trigger or running-status text, and the sliders reuse the existing /api/settings debounce flow so no backend route is added for live scheduler tuning. */ export const EngineControlMenu = forwardRef(function EngineControlMenu({ projectId }, ref) { const { t } = useTranslation("app"); const menuRef = useRef(null); const [open, setOpen] = useState(false); const { globalPaused, enginePaused, toggleGlobalPause, toggleEnginePause, refresh } = useAppSettings(projectId); const [concurrencyState, setConcurrencyState] = useState>({ status: "idle", data: null, error: null }); const [concurrencyDirty, setConcurrencyDirty] = useState(false); const [concurrencySaveState, setConcurrencySaveState] = useState<"idle" | "saving" | "saved" | "error">("idle"); const closeMenu = useCallback(() => setOpen(false), []); const openMenu = useCallback(() => setOpen(true), []); const toggleMenu = useCallback(() => setOpen((current) => !current), []); useImperativeHandle(ref, () => ({ open: openMenu, close: closeMenu, toggle: toggleMenu, }), [closeMenu, openMenu, toggleMenu]); useEffect(() => { if (!open) return; const handleClickOutside = (event: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { setOpen(false); } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); }; document.addEventListener("mousedown", handleClickOutside); document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("keydown", handleKeyDown); }; }, [open]); useEffect(() => { if (!open) return; let cancelled = false; setConcurrencyDirty(false); setConcurrencySaveState("idle"); setConcurrencyState({ status: "loading", data: null, error: null }); void (async () => { try { const [config, settings] = await Promise.all([fetchConfig(projectId), fetchSettings(projectId)]); if (!cancelled) { 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, }, error: null, }); } } catch (error) { if (!cancelled) { setConcurrencyState({ status: "error", data: DEFAULT_CONCURRENCY_VALUES, error: getErrorMessage(error, t("commandCenter.controls.concurrency.error", "Unable to load concurrency settings")), }); } } })(); return () => { cancelled = true; }; }, [open, projectId, t]); useEffect(() => { if (!open || !concurrencyDirty || !concurrencyState.data) return; const values = concurrencyState.data; const timeoutId = setTimeout(() => { setConcurrencySaveState("saving"); void updateSettings(values, projectId) .then(async () => { await refresh(); setConcurrencyDirty(false); setConcurrencySaveState("saved"); }) .catch(() => { setConcurrencySaveState("error"); }); }, CONCURRENCY_SAVE_DEBOUNCE_MS); return () => clearTimeout(timeoutId); }, [concurrencyDirty, concurrencyState.data, open, projectId, refresh]); const updateConcurrencyValue = (key: keyof ConcurrencyValues, rawValue: string, min: number, max: number) => { const nextValue = clamp(Number(rawValue), min, max); setConcurrencyState((current) => ({ status: "loaded", data: { ...(current.data ?? DEFAULT_CONCURRENCY_VALUES), [key]: nextValue }, error: null, })); setConcurrencyDirty(true); setConcurrencySaveState("idle"); }; const concurrencyValues = concurrencyState.data ?? DEFAULT_CONCURRENCY_VALUES; const saveLabel = concurrencyState.status === "loading" ? t("commandCenter.controls.status.loading", "Loading…") : concurrencySaveState === "saving" ? t("commandCenter.controls.status.saving", "Saving…") : concurrencySaveState === "saved" ? t("commandCenter.controls.status.saved", "Saved") : concurrencySaveState === "error" ? t("commandCenter.controls.status.saveError", "Save failed") : t("commandCenter.controls.status.ready", "Ready"); return (
{open && (
{t("commandCenter.controls.concurrency.title", "Concurrency")} {saveLabel}
{concurrencyState.status === "error" ?

{concurrencyState.error}

: null}
)}
); });