From b5378d232c2df61e6205d58e594363eb3284cd23 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 11:27:17 -0700 Subject: [PATCH] FN-7124: add engine-controls popover dismiss button Add an explicit dismiss affordance for the footer engine-controls popover while preserving pending slider saves. - Add a visible, localized Close engine controls X button inside the engine-control popover. - Flush pending project concurrency debounce writes before closing via button, Escape, outside-click, or trigger toggle. - Cover the dismiss affordance and flush behavior with dashboard tests and docs. - Add a patch changeset for the published CLI bundle. Files changed: .changeset/fn-7124-engine-controls-close.md | 6 ++ docs/dashboard-guide.md | 2 +- .../dashboard/app/components/EngineControlMenu.css | 19 +++++ .../dashboard/app/components/EngineControlMenu.tsx | 87 +++++++++++++++++----- .../__tests__/EngineControlMenu.test.tsx | 42 +++++++++++ packages/i18n/locales/en/app.json | 1 + packages/i18n/locales/es/app.json | 1 + packages/i18n/locales/fr/app.json | 1 + packages/i18n/locales/ko/app.json | 1 + packages/i18n/locales/zh-CN/app.json | 1 + packages/i18n/locales/zh-TW/app.json | 1 + 11 files changed, 142 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-7124 Fusion-Task-Lineage: cdd052e7-d4f1-4d4e-8348-002f7702a170 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7124-engine-controls-close.md | 6 ++ docs/dashboard-guide.md | 2 +- .../app/components/EngineControlMenu.css | 19 ++++ .../app/components/EngineControlMenu.tsx | 87 +++++++++++++++---- .../__tests__/EngineControlMenu.test.tsx | 42 +++++++++ packages/i18n/locales/en/app.json | 1 + packages/i18n/locales/es/app.json | 1 + packages/i18n/locales/fr/app.json | 1 + packages/i18n/locales/ko/app.json | 1 + packages/i18n/locales/zh-CN/app.json | 1 + packages/i18n/locales/zh-TW/app.json | 1 + 11 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 .changeset/fn-7124-engine-controls-close.md diff --git a/.changeset/fn-7124-engine-controls-close.md b/.changeset/fn-7124-engine-controls-close.md new file mode 100644 index 0000000000..769a52691e --- /dev/null +++ b/.changeset/fn-7124-engine-controls-close.md @@ -0,0 +1,6 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Add a visible close button to the footer engine-controls popover. +category: fix diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 2657ccbf84..d6edaa02b4 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1000,7 +1000,7 @@ Use this panel when upgrading a project with pre-FN-6245/FN-6277 in-review rows ### Executor footer engine controls -The global AI engine stop/start control and triage pause/resume control live in the executor footer status bar rather than the header. Select the small engine-controls button beside the executor state badge, or select the state text such as **Running**, to open the footer popover. The popover includes **Stop AI engine** / **Start AI engine**, **Pause triage** / **Resume scheduling**, and live scheduler sliders for max concurrent tasks, max triage concurrency, and max worktrees. 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, clamped to the track when usage exceeds the configured cap. Slider changes save through the existing `/api/settings` path with the same debounced behavior used by Command Center controls; no separate backend route is required. +The global AI engine stop/start control and triage pause/resume control live in the executor footer status bar rather than the header. Select the small engine-controls button beside the executor state badge, or select the state text such as **Running**, to open the footer popover. The popover includes **Stop AI engine** / **Start AI engine**, **Pause triage** / **Resume scheduling**, and live scheduler sliders for max concurrent tasks, max triage concurrency, and max worktrees. Use the visible **Close engine controls** X button, Escape, or outside-click to dismiss it. The global and current-project concurrency sliders also show how many agents are running, including actively-triaging planners (`triage` + `planning`, not paused), and a dot on the slider track for current use, clamped to the track when usage exceeds the configured cap. 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. Brief, single-poll executor stats fetch blips keep showing the last good footer stats instead of flashing **Connecting…**. The footer only switches to **Connecting…** for sustained suspension-like stats failures, or to an explicit error state for non-transient failures. diff --git a/packages/dashboard/app/components/EngineControlMenu.css b/packages/dashboard/app/components/EngineControlMenu.css index 2ebc70a665..1690f5bb49 100644 --- a/packages/dashboard/app/components/EngineControlMenu.css +++ b/packages/dashboard/app/components/EngineControlMenu.css @@ -31,6 +31,21 @@ color: var(--text); } +.engine-control-menu__header { + display: flex; + align-items: center; + justify-content: flex-end; + margin-block-end: calc(var(--space-xs) * -1); +} + +.engine-control-menu__close { + color: var(--text-muted); +} + +.engine-control-menu__close:hover { + color: var(--text); +} + .engine-control-menu__section { display: flex; flex-direction: column; @@ -152,4 +167,8 @@ width: auto; max-height: min(28rem, calc(100vh - var(--mobile-nav-height) - var(--space-2xl) - var(--space-lg))); } + + .engine-control-menu__header { + justify-content: flex-end; + } } diff --git a/packages/dashboard/app/components/EngineControlMenu.tsx b/packages/dashboard/app/components/EngineControlMenu.tsx index 80ef17dcc5..edba93fe05 100644 --- a/packages/dashboard/app/components/EngineControlMenu.tsx +++ b/packages/dashboard/app/components/EngineControlMenu.tsx @@ -2,7 +2,7 @@ import "./EngineControlMenu.css"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { DEFAULT_PROJECT_SETTINGS } from "@fusion/core"; -import { Pause, Play, SlidersHorizontal, Square } from "lucide-react"; +import { Pause, Play, SlidersHorizontal, Square, X } from "lucide-react"; import { fetchConfig, fetchSettings, updateSettings } from "../api/legacy"; import { useAppSettings } from "../hooks/useAppSettings"; // 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). @@ -84,12 +84,49 @@ export const EngineControlMenu = forwardRef>({ status: "idle", data: null, error: null }); const [concurrencyDirty, setConcurrencyDirty] = useState(false); const [concurrencySaveState, setConcurrencySaveState] = useState<"idle" | "saving" | "saved" | "error">("idle"); + const projectConcurrencySaveTimeoutRef = useRef | null>(null); + const pendingProjectConcurrencySaveRef = useRef(null); + // FNXC:EngineControls 2026-06-27-11:15: Closing the footer popover must not discard a just-dragged per-project concurrency value; flush the pending debounce before any explicit, outside-click, Escape, or trigger-close path hides the menu. // 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 closeMenu = useCallback(() => setOpen(false), []); + const saveProjectConcurrencyValues = useCallback((values: ConcurrencyValues) => { + setConcurrencySaveState("saving"); + void updateSettings(values, projectId) + .then(async () => { + await refresh(); + setConcurrencyDirty(false); + setConcurrencySaveState("saved"); + }) + .catch(() => { + setConcurrencySaveState("error"); + }); + }, [projectId, refresh]); + + const clearProjectConcurrencySaveTimeout = useCallback(() => { + if (projectConcurrencySaveTimeoutRef.current) { + clearTimeout(projectConcurrencySaveTimeoutRef.current); + projectConcurrencySaveTimeoutRef.current = null; + } + }, []); + + const flushProjectConcurrencySave = useCallback(() => { + const pendingValues = pendingProjectConcurrencySaveRef.current; + if (!pendingValues) return; + clearProjectConcurrencySaveTimeout(); + pendingProjectConcurrencySaveRef.current = null; + saveProjectConcurrencyValues(pendingValues); + }, [clearProjectConcurrencySaveTimeout, saveProjectConcurrencyValues]); + + const closeMenu = useCallback(() => { + flushProjectConcurrencySave(); + setOpen(false); + }, [flushProjectConcurrencySave]); const openMenu = useCallback(() => setOpen(true), []); - const toggleMenu = useCallback(() => setOpen((current) => !current), []); + const toggleMenu = useCallback(() => { + if (open) flushProjectConcurrencySave(); + setOpen((current) => !current); + }, [flushProjectConcurrencySave, open]); useImperativeHandle(ref, () => ({ open: openMenu, @@ -102,12 +139,12 @@ export const EngineControlMenu = forwardRef { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setOpen(false); + closeMenu(); } }; const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); + if (event.key === "Escape") closeMenu(); }; document.addEventListener("mousedown", handleClickOutside); @@ -116,7 +153,7 @@ export const EngineControlMenu = forwardRef { if (!open) return; @@ -156,20 +193,19 @@ export const EngineControlMenu = forwardRef { 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"); - }); + pendingProjectConcurrencySaveRef.current = values; + projectConcurrencySaveTimeoutRef.current = setTimeout(() => { + pendingProjectConcurrencySaveRef.current = null; + projectConcurrencySaveTimeoutRef.current = null; + saveProjectConcurrencyValues(values); }, CONCURRENCY_SAVE_DEBOUNCE_MS); - return () => clearTimeout(timeoutId); - }, [concurrencyDirty, concurrencyState.data, open, projectId, refresh]); + return () => { + clearProjectConcurrencySaveTimeout(); + if (pendingProjectConcurrencySaveRef.current === values) { + pendingProjectConcurrencySaveRef.current = null; + } + }; + }, [clearProjectConcurrencySaveTimeout, concurrencyDirty, concurrencyState.data, open, saveProjectConcurrencyValues]); const updateConcurrencyValue = (key: keyof ConcurrencyValues, rawValue: string, min: number, max: number) => { const nextValue = clamp(Number(rawValue), min, max); @@ -228,6 +264,19 @@ export const EngineControlMenu = forwardRef + {/* FNXC:EngineControls 2026-06-27-00:00: FN-7124 requires the footer engine-controls popover to expose an explicit dismiss affordance in addition to outside-click and Escape, and it must stay inside the popover so desktop and mobile layouts both keep it visible and tappable. */} +
+ +