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
|
### 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.
|
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);
|
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 {
|
.engine-control-menu__section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -152,4 +167,8 @@
|
|||||||
width: auto;
|
width: auto;
|
||||||
max-height: min(28rem, calc(100vh - var(--mobile-nav-height) - var(--space-2xl) - var(--space-lg)));
|
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 { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type CSSProperties } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DEFAULT_PROJECT_SETTINGS } from "@fusion/core";
|
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 { fetchConfig, fetchSettings, updateSettings } from "../api/legacy";
|
||||||
import { useAppSettings } from "../hooks/useAppSettings";
|
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).
|
// 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 [concurrencyState, setConcurrencyState] = useState<AsyncState<ConcurrencyValues>>({ status: "idle", data: null, error: null });
|
||||||
const [concurrencyDirty, setConcurrencyDirty] = useState(false);
|
const [concurrencyDirty, setConcurrencyDirty] = useState(false);
|
||||||
const [concurrencySaveState, setConcurrencySaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
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.
|
// 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 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 openMenu = useCallback(() => setOpen(true), []);
|
||||||
const toggleMenu = useCallback(() => setOpen((current) => !current), []);
|
const toggleMenu = useCallback(() => {
|
||||||
|
if (open) flushProjectConcurrencySave();
|
||||||
|
setOpen((current) => !current);
|
||||||
|
}, [flushProjectConcurrencySave, open]);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
open: openMenu,
|
open: openMenu,
|
||||||
@@ -102,12 +139,12 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
setOpen(false);
|
closeMenu();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape") setOpen(false);
|
if (event.key === "Escape") closeMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
@@ -116,7 +153,7 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
document.removeEventListener("mousedown", handleClickOutside);
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [closeMenu, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -156,20 +193,19 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !concurrencyDirty || !concurrencyState.data) return;
|
if (!open || !concurrencyDirty || !concurrencyState.data) return;
|
||||||
const values = concurrencyState.data;
|
const values = concurrencyState.data;
|
||||||
const timeoutId = setTimeout(() => {
|
pendingProjectConcurrencySaveRef.current = values;
|
||||||
setConcurrencySaveState("saving");
|
projectConcurrencySaveTimeoutRef.current = setTimeout(() => {
|
||||||
void updateSettings(values, projectId)
|
pendingProjectConcurrencySaveRef.current = null;
|
||||||
.then(async () => {
|
projectConcurrencySaveTimeoutRef.current = null;
|
||||||
await refresh();
|
saveProjectConcurrencyValues(values);
|
||||||
setConcurrencyDirty(false);
|
|
||||||
setConcurrencySaveState("saved");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setConcurrencySaveState("error");
|
|
||||||
});
|
|
||||||
}, CONCURRENCY_SAVE_DEBOUNCE_MS);
|
}, CONCURRENCY_SAVE_DEBOUNCE_MS);
|
||||||
return () => clearTimeout(timeoutId);
|
return () => {
|
||||||
}, [concurrencyDirty, concurrencyState.data, open, projectId, refresh]);
|
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 updateConcurrencyValue = (key: keyof ConcurrencyValues, rawValue: string, min: number, max: number) => {
|
||||||
const nextValue = clamp(Number(rawValue), min, max);
|
const nextValue = clamp(Number(rawValue), min, max);
|
||||||
@@ -228,6 +264,19 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="card engine-control-menu__popover" role="menu" aria-label={t("executor.engineControls", "Engine controls")} data-testid="engine-control-menu">
|
<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">
|
<div className="engine-control-menu__section engine-control-menu__section--actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -78,6 +78,48 @@ describe("EngineControlMenu", () => {
|
|||||||
vi.useRealTimers();
|
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 () => {
|
it("stops and starts the global AI engine via settings", async () => {
|
||||||
apiMocks.fetchSettings.mockResolvedValue({ ...defaultSettings, globalPause: false });
|
apiMocks.fetchSettings.mockResolvedValue({ ...defaultSettings, globalPause: false });
|
||||||
await openMenu();
|
await openMenu();
|
||||||
|
|||||||
@@ -2320,6 +2320,7 @@
|
|||||||
"escalated": "Escalated",
|
"escalated": "Escalated",
|
||||||
"escalatedSuffix": " (escalated)",
|
"escalatedSuffix": " (escalated)",
|
||||||
"engineControls": "Engine controls",
|
"engineControls": "Engine controls",
|
||||||
|
"engineControlsClose": "Close engine controls",
|
||||||
"hideProjectDir": "Hide project directory",
|
"hideProjectDir": "Hide project directory",
|
||||||
"hoursAgo_one": "{{count}}h ago",
|
"hoursAgo_one": "{{count}}h ago",
|
||||||
"hoursAgo_other": "{{count}}h ago",
|
"hoursAgo_other": "{{count}}h ago",
|
||||||
|
|||||||
@@ -2337,6 +2337,7 @@
|
|||||||
"temporary": "Temporal",
|
"temporary": "Temporal",
|
||||||
"todoStatus": "",
|
"todoStatus": "",
|
||||||
"engineControls": "Engine controls",
|
"engineControls": "Engine controls",
|
||||||
|
"engineControlsClose": "Close engine controls",
|
||||||
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||||
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2337,6 +2337,7 @@
|
|||||||
"temporary": "Temporaire",
|
"temporary": "Temporaire",
|
||||||
"todoStatus": "",
|
"todoStatus": "",
|
||||||
"engineControls": "Engine controls",
|
"engineControls": "Engine controls",
|
||||||
|
"engineControlsClose": "Close engine controls",
|
||||||
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||||
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2337,6 +2337,7 @@
|
|||||||
"temporary": "임시",
|
"temporary": "임시",
|
||||||
"todoStatus": "",
|
"todoStatus": "",
|
||||||
"engineControls": "Engine controls",
|
"engineControls": "Engine controls",
|
||||||
|
"engineControlsClose": "Close engine controls",
|
||||||
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||||
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2337,6 +2337,7 @@
|
|||||||
"temporary": "临时",
|
"temporary": "临时",
|
||||||
"todoStatus": "",
|
"todoStatus": "",
|
||||||
"engineControls": "Engine controls",
|
"engineControls": "Engine controls",
|
||||||
|
"engineControlsClose": "Close engine controls",
|
||||||
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||||
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2337,6 +2337,7 @@
|
|||||||
"temporary": "暫時",
|
"temporary": "暫時",
|
||||||
"todoStatus": "",
|
"todoStatus": "",
|
||||||
"engineControls": "Engine controls",
|
"engineControls": "Engine controls",
|
||||||
|
"engineControlsClose": "Close engine controls",
|
||||||
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||||
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user