diff --git a/.changeset/fn-7340-footer-concurrency-dot-alignment.md b/.changeset/fn-7340-footer-concurrency-dot-alignment.md new file mode 100644 index 0000000000..15d5b59c4e --- /dev/null +++ b/.changeset/fn-7340-footer-concurrency-dot-alignment.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Align footer concurrency current-use dots with Command Center sliders. +category: fix +dev: Mirrors Command Center range geometry in the footer Engine Controls popup while preserving current/cap utilization math. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index e66dc6fef2..51a0656d43 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1087,7 +1087,8 @@ Use this panel when upgrading a project with pre-FN-6245/FN-6277 in-review rows -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. On mobile, narrow tablets, and tablet landscape, the same controls open as a full-width bottom panel above the executor footer and mobile navigation so the close button and sliders remain reachable. 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. Changed concurrency slider values ask for confirmation after the value settles. Confirming saves the global cap through `/api/global-concurrency` and project caps through `/api/settings`; cancel, backdrop dismissal, Escape, close, outside-click, or unmount reverts unconfirmed slider edits without saving. Multiple changed project sliders within one debounce window are summarized in one confirmation dialog, matching Command Center behavior. + +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. On mobile, narrow tablets, and tablet landscape, the same controls open as a full-width bottom panel above the executor footer and mobile navigation so the close button and sliders remain reachable. 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 and shares the same track/thumb geometry as Command Center sliders, so one running agent renders above the start of the track, zero stays at the start, and over-cap usage clamps to the end. Changed concurrency slider values ask for confirmation after the value settles. Confirming saves the global cap through `/api/global-concurrency` and project caps through `/api/settings`; cancel, backdrop dismissal, Escape, close, outside-click, or unmount reverts unconfirmed slider edits without saving. Multiple changed project sliders within one debounce window are summarized in one confirmation dialog, matching Command Center behavior. 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. diff --git a/packages/dashboard/app/components/EngineControlMenu.css b/packages/dashboard/app/components/EngineControlMenu.css index 2b601d01f8..18431ab149 100644 --- a/packages/dashboard/app/components/EngineControlMenu.css +++ b/packages/dashboard/app/components/EngineControlMenu.css @@ -138,8 +138,14 @@ } .engine-control-menu__range { - width: 100%; + /* + FNXC:GlobalConcurrencyControls 2026-06-30-21:45: + Footer current-use dots must align with the Command Center concurrency sliders. Mirror the dashboard range-input geometry instead of nudging the marker so the shared current/cap marker math lands on the same effective track/thumb positions across footer and dashboard surfaces. + */ + inline-size: 100%; + min-block-size: var(--space-xl); accent-color: var(--accent); + touch-action: pan-y; } /* FNXC:GlobalConcurrencyControls 2026-06-26-06:26: The current-use marker reuses the global .status-dot convention and is positioned with logical inset properties so utilization is visible on LTR/RTL slider tracks without intercepting drag input. */ @@ -164,6 +170,26 @@ bottom: calc(100% + var(--space-xs)); } +@media (max-width: 768px) { + .engine-control-menu__range-wrap { + --engine-control-range-thumb-size: var(--space-xl); + } + + .engine-control-menu__range { + min-block-size: var(--space-2xl); + } + + .engine-control-menu__range::-webkit-slider-thumb { + inline-size: var(--space-xl); + block-size: var(--space-xl); + } + + .engine-control-menu__range::-moz-range-thumb { + inline-size: var(--space-xl); + block-size: var(--space-xl); + } +} + @media (max-width: 1024px) { .engine-control-menu__popover { /* diff --git a/packages/dashboard/app/components/__tests__/EngineControlMenu.test.tsx b/packages/dashboard/app/components/__tests__/EngineControlMenu.test.tsx index 5dd0887e58..98af133866 100644 --- a/packages/dashboard/app/components/__tests__/EngineControlMenu.test.tsx +++ b/packages/dashboard/app/components/__tests__/EngineControlMenu.test.tsx @@ -1,8 +1,20 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, act, cleanup } from "@testing-library/react"; import { EngineControlMenu } from "../EngineControlMenu"; import { ConfirmDialogProvider } from "../../hooks/useConfirm"; +const engineControlMenuCss = readFileSync( + join(process.cwd(), "app/components/EngineControlMenu.css"), + "utf8", +); + +const commandCenterControlsCss = readFileSync( + join(process.cwd(), "app/components/command-center/CommandCenterControls.css"), + "utf8", +); + const defaultSettings = { maxConcurrent: 2, maxTriageConcurrent: 1, @@ -65,6 +77,19 @@ function expectUseMarkerPct(testId: string, pct: string) { expect(screen.getByTestId(testId).style.getPropertyValue("--use-pct")).toBe(pct); } +function expectFooterUseOffset(testId: string, ratio: number) { + expect(screen.getByTestId(testId).style.getPropertyValue("--use-offset")).toBe( + `calc((var(--engine-control-range-thumb-size) / 2) + ((100% - var(--engine-control-range-thumb-size)) * ${ratio}))`, + ); +} + +function cssRule(css: string, selector: string) { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([\\s\\S]*?)\\}`)); + expect(match, `Expected CSS rule for ${selector}`).not.toBeNull(); + return match?.[1] ?? ""; +} + describe("EngineControlMenu", () => { beforeEach(() => { vi.useRealTimers(); @@ -89,6 +114,36 @@ describe("EngineControlMenu", () => { vi.useRealTimers(); }); + + it("matches the Command Center slider geometry contract for footer current-use markers", () => { + const footerWrap = cssRule(engineControlMenuCss, ".engine-control-menu__range-wrap"); + const footerRange = cssRule(engineControlMenuCss, ".engine-control-menu__range"); + const footerMarker = cssRule(engineControlMenuCss, ".engine-control-menu__use-marker"); + const commandWrap = cssRule(commandCenterControlsCss, ".cc-controls-range-wrap"); + const commandTouchSliderRule = commandCenterControlsCss.match(/\.cc-controls-slider input\[type="range"\],\n\.cc-controls-touch-slider\s*\{([\s\S]*?)\}/)?.[1] ?? ""; + const commandMarker = cssRule(commandCenterControlsCss, ".cc-controls-use-marker"); + + expect(footerWrap).toContain("--engine-control-range-thumb-size: var(--space-md);"); + expect(commandWrap).toContain("--cc-controls-range-thumb-size: var(--space-md);"); + expect(footerWrap).toContain("position: relative;"); + expect(footerWrap).toContain("display: flex;"); + expect(footerWrap).toContain("align-items: center;"); + expect(footerRange).toContain("inline-size: 100%;"); + expect(footerRange).toContain("min-block-size: var(--space-xl);"); + expect(footerRange).toContain("accent-color: var(--accent);"); + expect(footerRange).toContain("touch-action: pan-y;"); + expect(commandTouchSliderRule).toContain("inline-size: 100%;"); + expect(commandTouchSliderRule).toContain("min-block-size: var(--space-xl);"); + expect(commandTouchSliderRule).toContain("accent-color: var(--accent);"); + expect(commandTouchSliderRule).toContain("touch-action: pan-y;"); + for (const declaration of ["position: absolute;", "inset-block-start: 50%;", "inset-inline-start: var(--use-offset, var(--use-pct));", "transform: translate(-50%, -50%);", "pointer-events: none;"]) { + expect(footerMarker).toContain(declaration); + expect(commandMarker).toContain(declaration); + } + expect(engineControlMenuCss).toContain("@media (max-width: 768px)"); + expect(engineControlMenuCss).toContain("--engine-control-range-thumb-size: var(--space-xl);"); + }); + it("renders an explicit close button when opened", async () => { await openMenu(); @@ -570,6 +625,36 @@ describe("EngineControlMenu", () => { expect(screen.getByTestId("engine-control-project-running")).toHaveTextContent("0 running (this project)"); expect(screen.getByTestId("engine-control-global-use-marker")).toHaveStyle({ "--use-pct": "0%" }); expect(screen.getByTestId("engine-control-project-use-marker")).toHaveStyle({ "--use-pct": "0%" }); + expectFooterUseOffset("engine-control-global-use-marker", 0); + expectFooterUseOffset("engine-control-project-use-marker", 0); + }); + + it("recomputes footer project marker positions from the visible pending cap", async () => { + legacyMocks.fetchSettings.mockResolvedValue({ + ...defaultSettings, + maxConcurrent: 60, + }); + mockGlobalConcurrency({ + globalMaxConcurrent: 48, + currentlyActive: 16, + projectsActive: { proj_123: 30 }, + }); + + await openMenu(); + + await screen.findByLabelText(/maximum concurrent agents across all projects/i); + const maxConcurrent = screen.getByLabelText(/max concurrent tasks/i); + vi.useFakeTimers(); + + expectUseMarkerPct("engine-control-global-use-marker", `${(16 / 48) * 100}%`); + expectUseMarkerPct("engine-control-project-use-marker", "50%"); + + fireEvent.change(maxConcurrent, { target: { value: "50" } }); + + expectUseMarkerPct("engine-control-global-use-marker", `${(16 / 48) * 100}%`); + expectUseMarkerPct("engine-control-project-use-marker", "60%"); + expectFooterUseOffset("engine-control-global-use-marker", 16 / 48); + expectFooterUseOffset("engine-control-project-use-marker", 30 / 50); }); it("suppresses footer running counts and markers while utilization is loading", async () => {