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) <noreply@runfusion.ai>
This commit is contained in:
6
.changeset/fn-7124-engine-controls-close.md
Normal file
6
.changeset/fn-7124-engine-controls-close.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Add a visible close button to the footer engine-controls popover.
|
||||
category: fix
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EngineControlMenuHandle, EngineContr
|
||||
const [concurrencyState, setConcurrencyState] = useState<AsyncState<ConcurrencyValues>>({ status: "idle", data: null, error: null });
|
||||
const [concurrencyDirty, setConcurrencyDirty] = useState(false);
|
||||
const [concurrencySaveState, setConcurrencySaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const projectConcurrencySaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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.
|
||||
// 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<EngineControlMenuHandle, EngineContr
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
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<EngineControlMenuHandle, EngineContr
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
}, [closeMenu, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -156,20 +193,19 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
||||
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");
|
||||
});
|
||||
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<EngineControlMenuHandle, EngineContr
|
||||
|
||||
{open && (
|
||||
<div className="card engine-control-menu__popover" role="menu" aria-label={t("executor.engineControls", "Engine controls")} data-testid="engine-control-menu">
|
||||
{/* 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. */}
|
||||
<div className="engine-control-menu__header">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon engine-control-menu__close"
|
||||
onClick={closeMenu}
|
||||
title={t("executor.engineControlsClose", "Close engine controls")}
|
||||
aria-label={t("executor.engineControlsClose", "Close engine controls")}
|
||||
data-testid="engine-control-menu-close"
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="engine-control-menu__section engine-control-menu__section--actions">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -78,6 +78,48 @@ describe("EngineControlMenu", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders an explicit close button when opened", async () => {
|
||||
await openMenu();
|
||||
|
||||
expect(screen.getByTestId("engine-control-menu-close")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/close engine controls/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the menu when the explicit close button is clicked", async () => {
|
||||
await openMenu();
|
||||
|
||||
fireEvent.click(screen.getByTestId("engine-control-menu-close"));
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId("engine-control-menu")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("flushes pending project concurrency changes when the explicit close button is clicked", async () => {
|
||||
await openMenu();
|
||||
|
||||
const maxConcurrent = await screen.findByLabelText(/max concurrent tasks/i);
|
||||
vi.useFakeTimers();
|
||||
|
||||
fireEvent.change(maxConcurrent, { target: { value: "7" } });
|
||||
fireEvent.click(screen.getByTestId("engine-control-menu-close"));
|
||||
|
||||
expect(legacyMocks.updateSettings).toHaveBeenCalledWith(
|
||||
{ maxConcurrent: 7, maxTriageConcurrent: 1, maxWorktrees: 4 },
|
||||
"proj_123",
|
||||
);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
});
|
||||
expect(legacyMocks.updateSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the close button available when concurrency settings fail to load", async () => {
|
||||
legacyMocks.fetchSettings.mockRejectedValue(new Error("settings unavailable"));
|
||||
await openMenu();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("settings unavailable");
|
||||
expect(screen.getByTestId("engine-control-menu-close")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stops and starts the global AI engine via settings", async () => {
|
||||
apiMocks.fetchSettings.mockResolvedValue({ ...defaultSettings, globalPause: false });
|
||||
await openMenu();
|
||||
|
||||
@@ -2320,6 +2320,7 @@
|
||||
"escalated": "Escalated",
|
||||
"escalatedSuffix": " (escalated)",
|
||||
"engineControls": "Engine controls",
|
||||
"engineControlsClose": "Close engine controls",
|
||||
"hideProjectDir": "Hide project directory",
|
||||
"hoursAgo_one": "{{count}}h ago",
|
||||
"hoursAgo_other": "{{count}}h ago",
|
||||
|
||||
@@ -2337,6 +2337,7 @@
|
||||
"temporary": "Temporal",
|
||||
"todoStatus": "",
|
||||
"engineControls": "Engine controls",
|
||||
"engineControlsClose": "Close engine controls",
|
||||
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||
},
|
||||
|
||||
@@ -2337,6 +2337,7 @@
|
||||
"temporary": "Temporaire",
|
||||
"todoStatus": "",
|
||||
"engineControls": "Engine controls",
|
||||
"engineControlsClose": "Close engine controls",
|
||||
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||
},
|
||||
|
||||
@@ -2337,6 +2337,7 @@
|
||||
"temporary": "임시",
|
||||
"todoStatus": "",
|
||||
"engineControls": "Engine controls",
|
||||
"engineControlsClose": "Close engine controls",
|
||||
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||
},
|
||||
|
||||
@@ -2337,6 +2337,7 @@
|
||||
"temporary": "临时",
|
||||
"todoStatus": "",
|
||||
"engineControls": "Engine controls",
|
||||
"engineControlsClose": "Close engine controls",
|
||||
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||
},
|
||||
|
||||
@@ -2337,6 +2337,7 @@
|
||||
"temporary": "暫時",
|
||||
"todoStatus": "",
|
||||
"engineControls": "Engine controls",
|
||||
"engineControlsClose": "Close engine controls",
|
||||
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user