FN-7235: align footer concurrency use markers
Align footer concurrency indicators with absolute running-agent utilization. - Change footer use-marker ratio math to use current running count over configured cap. - Cover zero, one-active, mid-track, over-cap, loading, and error marker states in EngineControlMenu tests. - Document the footer marker behavior and add a patch changeset for the published CLI package. Files changed: .changeset/fn-7235-footer-concurrency-marker.md | 7 +++ docs/dashboard-guide.md | 3 +- .../dashboard/app/components/EngineControlMenu.tsx | 14 +++-- .../__tests__/EngineControlMenu.test.tsx | 70 +++++++++++++++++++--- 4 files changed, 79 insertions(+), 15 deletions(-) Fusion-Task-Id: FN-7235 Fusion-Task-Lineage: 0dda1277-2d89-4195-882c-6488dfc67288 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7235-footer-concurrency-marker.md
Normal file
7
.changeset/fn-7235-footer-concurrency-marker.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Footer concurrency markers now line up with the running-agent counts.
|
||||
category: fix
|
||||
dev: Align EngineControlMenu current-use marker math with CommandCenterControls by mapping utilization as current / cap instead of slider min/max coordinates.
|
||||
@@ -1030,7 +1030,8 @@ 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. 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.
|
||||
<!-- FNXC:ExecutorStatusBar 2026-06-29-00:00: FN-7235 documents that footer concurrency current-use dots use the same absolute utilization math as Command Center controls, so running-agent counts visually align with the slider track instead of the editable slider minimum. -->
|
||||
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. The dot uses absolute utilization (`running / cap`) rather than range-slider coordinates, so one running agent renders above the start of the track, zero stays at the start, and over-cap usage clamps to the end. 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.
|
||||
|
||||
<!-- FNXC:ExecutorStatusBar 2026-06-27-00:00: FN-7163 makes footer stats loading initial-only so routine heartbeat refreshes keep the populated footer and open concurrency popover mounted instead of blinking to the loading branch. -->
|
||||
Brief, single-poll executor stats fetch blips keep showing the last good footer stats instead of flashing **Connecting…**. Routine executor stats heartbeats also keep the populated footer mounted after initial load, so an open engine/concurrency popover stays open while counts refresh. The footer only switches to **Connecting…** for sustained suspension-like stats failures, or to an explicit error state for non-transient failures.
|
||||
|
||||
@@ -54,9 +54,13 @@ function getErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
function getUseMarkerRatio(current: number, min: number, max: number) {
|
||||
if (max <= min) return 0;
|
||||
return clamp((current - min) / (max - min), 0, 1);
|
||||
/*
|
||||
FNXC:GlobalConcurrencyControls 2026-06-29-10:30:
|
||||
FN-7235 keeps the footer current-use marker consistent with FN-7160 Command Center behavior: it shows absolute utilization on a 0..cap scale. Do not subtract the range input floor of 1, because one running agent must render above zero even though the editable slider cannot be set to 0.
|
||||
*/
|
||||
function getUseMarkerRatio(current: number, max: number) {
|
||||
if (max <= 0) return 0;
|
||||
return clamp(current / max, 0, 1);
|
||||
}
|
||||
|
||||
function getUseMarkerStyle(ratio: number): CSSProperties {
|
||||
@@ -244,8 +248,8 @@ export const EngineControlMenu = forwardRef<EngineControlMenuHandle, EngineContr
|
||||
const globalCountsLoaded = gc.status === "loaded";
|
||||
const projectActive = gc.projectActiveCount(projectId);
|
||||
const maxConcurrentSliderMax = getConcurrencySliderMax("maxConcurrent", concurrencyValues.maxConcurrent);
|
||||
const globalUseMarkerRatio = getUseMarkerRatio(gc.currentlyActive, gc.min, gc.sliderMax);
|
||||
const projectUseMarkerRatio = getUseMarkerRatio(projectActive, CONCURRENCY_SLIDER_LIMITS.maxConcurrent.min, maxConcurrentSliderMax);
|
||||
const globalUseMarkerRatio = getUseMarkerRatio(gc.currentlyActive, gc.sliderMax);
|
||||
const projectUseMarkerRatio = getUseMarkerRatio(projectActive, maxConcurrentSliderMax);
|
||||
|
||||
return (
|
||||
<div className="engine-control-menu" ref={menuRef}>
|
||||
|
||||
@@ -54,6 +54,12 @@ function mockGlobalConcurrency(overrides: Partial<{
|
||||
});
|
||||
}
|
||||
|
||||
// FNXC:EngineControls 2026-06-29-12:00: FN-7235 reproduces the footer mismatch by asserting running-count markers use the loaded cap (`current / cap`) rather than expanded slider-track coordinates; both global and project renderers must move 1 running agent above zero.
|
||||
// FNXC:EngineControls 2026-06-29-13:25: Keep the footer marker guard aligned with the Command Center representative states: zero, one active, mid-track utilization, over-cap clamping, loading, and error.
|
||||
function expectUseMarkerPct(testId: string, pct: string) {
|
||||
expect(screen.getByTestId(testId).style.getPropertyValue("--use-pct")).toBe(pct);
|
||||
}
|
||||
|
||||
describe("EngineControlMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -218,7 +224,7 @@ describe("EngineControlMenu", () => {
|
||||
expect(screen.getByLabelText(/max worktrees/i)).toHaveAttribute("max", "50");
|
||||
});
|
||||
|
||||
it("renders running counts and current-use markers with clamped slider positions", async () => {
|
||||
it("renders running counts and current-use markers with clamped absolute utilization", async () => {
|
||||
legacyMocks.fetchSettings.mockResolvedValue({
|
||||
...defaultSettings,
|
||||
maxConcurrent: 50,
|
||||
@@ -237,20 +243,66 @@ describe("EngineControlMenu", () => {
|
||||
expect(screen.getByTestId("engine-control-project-use-marker")).toHaveStyle({ "--use-pct": "100%" });
|
||||
});
|
||||
|
||||
it("positions current-use markers at zero and mid-track for representative running counts", async () => {
|
||||
it("positions current-use markers by absolute utilization instead of slider-coordinate math", async () => {
|
||||
legacyMocks.fetchSettings.mockResolvedValue({
|
||||
...defaultSettings,
|
||||
maxConcurrent: 50,
|
||||
});
|
||||
mockGlobalConcurrency({
|
||||
globalMaxConcurrent: 33,
|
||||
globalMaxConcurrent: 50,
|
||||
currentlyActive: 17,
|
||||
projectsActive: { proj_123: 0 },
|
||||
projectsActive: { proj_123: 17 },
|
||||
});
|
||||
|
||||
await openMenu();
|
||||
|
||||
expect(await screen.findByTestId("engine-control-global-use-marker")).toHaveStyle({ "--use-pct": "50%" });
|
||||
expect(screen.getByTestId("engine-control-project-use-marker")).toHaveStyle({ "--use-pct": "0%" });
|
||||
await screen.findByTestId("engine-control-global-use-marker");
|
||||
expectUseMarkerPct("engine-control-global-use-marker", "34%");
|
||||
expectUseMarkerPct("engine-control-project-use-marker", "34%");
|
||||
expect(screen.getByTestId("engine-control-global-use-marker").style.getPropertyValue("--use-pct")).not.toBe(`${((17 - 1) / (50 - 1)) * 100}%`);
|
||||
expect(screen.getByTestId("engine-control-project-use-marker").style.getPropertyValue("--use-pct")).not.toBe(`${((17 - 1) / (50 - 1)) * 100}%`);
|
||||
expect(screen.queryAllByTestId(/engine-control-.*-use-marker/)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("defaults the current-project running count to zero for empty projectsActive and missing projectId", async () => {
|
||||
it("positions mid-track footer markers using absolute utilization", async () => {
|
||||
legacyMocks.fetchSettings.mockResolvedValue({
|
||||
...defaultSettings,
|
||||
maxConcurrent: 10,
|
||||
});
|
||||
mockGlobalConcurrency({
|
||||
globalMaxConcurrent: 10,
|
||||
currentlyActive: 6,
|
||||
projectsActive: { proj_123: 6 },
|
||||
});
|
||||
|
||||
await openMenu();
|
||||
|
||||
await screen.findByTestId("engine-control-global-use-marker");
|
||||
expectUseMarkerPct("engine-control-global-use-marker", "18.75%");
|
||||
expectUseMarkerPct("engine-control-project-use-marker", "12%");
|
||||
});
|
||||
|
||||
it("keeps one active agent visibly above zero on both footer markers", async () => {
|
||||
legacyMocks.fetchSettings.mockResolvedValue({
|
||||
...defaultSettings,
|
||||
maxConcurrent: 10,
|
||||
});
|
||||
mockGlobalConcurrency({
|
||||
globalMaxConcurrent: 10,
|
||||
currentlyActive: 1,
|
||||
projectsActive: { proj_123: 1 },
|
||||
});
|
||||
|
||||
await openMenu();
|
||||
|
||||
await screen.findByTestId("engine-control-global-use-marker");
|
||||
expectUseMarkerPct("engine-control-global-use-marker", "3.125%");
|
||||
expectUseMarkerPct("engine-control-project-use-marker", "2%");
|
||||
expect(screen.getByTestId("engine-control-global-use-marker").style.getPropertyValue("--use-pct")).not.toBe("0%");
|
||||
expect(screen.getByTestId("engine-control-project-use-marker").style.getPropertyValue("--use-pct")).not.toBe("0%");
|
||||
});
|
||||
|
||||
it("positions zero running at the start of both footer markers", async () => {
|
||||
mockGlobalConcurrency({
|
||||
globalMaxConcurrent: 6,
|
||||
currentlyActive: 0,
|
||||
@@ -265,7 +317,7 @@ describe("EngineControlMenu", () => {
|
||||
expect(screen.getByTestId("engine-control-project-use-marker")).toHaveStyle({ "--use-pct": "0%" });
|
||||
});
|
||||
|
||||
it("does not render running counts or markers while global concurrency is loading", async () => {
|
||||
it("suppresses footer running counts and markers while utilization is loading", async () => {
|
||||
let resolveGlobalConcurrency!: (value: {
|
||||
globalMaxConcurrent: number;
|
||||
currentlyActive: number;
|
||||
@@ -293,7 +345,7 @@ describe("EngineControlMenu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render running counts or markers when global concurrency fails to load", async () => {
|
||||
it("suppresses footer running counts and markers when utilization fails", async () => {
|
||||
legacyMocks.fetchGlobalConcurrency.mockRejectedValue(new Error("global concurrency unavailable"));
|
||||
|
||||
await openMenu();
|
||||
|
||||
Reference in New Issue
Block a user