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:
gsxdsm
2026-06-27 11:27:17 -07:00
parent cb6838a63f
commit b5378d232c
11 changed files with 142 additions and 20 deletions

View File

@@ -0,0 +1,6 @@
---
"@runfusion/fusion": patch
---
summary: Add a visible close button to the footer engine-controls popover.
category: fix

View File

@@ -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.

View File

@@ -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;
}
}

View File

@@ -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"

View File

@@ -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();

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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"
},

View File

@@ -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"
},

View File

@@ -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"
},

View File

@@ -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"
},