FN-7273: fix mobile engine controls menu placement
Keep the footer engine controls reachable on constrained mobile and tablet viewports. - Render footer engine controls as a fixed full-width bottom panel through tablet widths while preserving the desktop anchored popover. - Account for executor footer, mobile navigation, safe-area, standalone, and ICB offsets in panel placement. - Add CSS regression coverage, dashboard documentation, and a patch changeset. Files changed: .changeset/fn-7273-mobile-engine-controls-menu.md | 7 ++ docs/dashboard-guide.md | 3 +- .../dashboard/app/components/EngineControlMenu.css | 38 +++++++++- .../__tests__/EngineControlMenu.css.test.ts | 88 +++++++++++++++++++++- 4 files changed, 128 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-7273 Fusion-Task-Lineage: d4764555-c8eb-4086-9130-d2f568727ae1 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7273-mobile-engine-controls-menu.md
Normal file
7
.changeset/fn-7273-mobile-engine-controls-menu.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix the mobile Engine Controls menu placement.
|
||||
category: fix
|
||||
dev: Keeps the footer EngineControlMenu as a viewport-safe mobile/tablet bottom panel while preserving the desktop anchored popover.
|
||||
@@ -1077,7 +1077,8 @@ Use this panel when upgrading a project with pre-FN-6245/FN-6277 in-review rows
|
||||
|
||||
<!-- 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. -->
|
||||
<!-- FNXC:ExecutorStatusBar 2026-06-29-19:09: FN-7248 makes footer concurrency edits confirmation-gated like Command Center. Closing the popover, outside-clicking, pressing Escape, dismissing the backdrop, or unmounting must revert unconfirmed slider edits instead of saving them. -->
|
||||
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. 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.
|
||||
<!-- FNXC:ExecutorStatusBar 2026-06-30-16:42: FN-7273 keeps the footer Engine Controls popover usable on mobile, narrow tablets, and tablet landscape by documenting that constrained screens use a full-width bottom panel above both fixed bottom bars instead of the compact desktop anchor. -->
|
||||
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.
|
||||
|
||||
<!-- 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.
|
||||
|
||||
@@ -158,14 +158,46 @@
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.executor-status-bar__segment--engine-controls .engine-control-menu > .engine-control-menu__popover.card {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + var(--space-xs));
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.engine-control-menu__popover {
|
||||
/*
|
||||
FNXC:EngineControls 2026-06-30-07:40:
|
||||
The footer Engine Controls menu opens from a fixed mobile footer, so the mobile panel must not inherit desktop corner anchoring. Keep it viewport-fixed, full-width between token gutters, scrollable, and lifted above the executor footer plus mobile nav stack including safe-area, standalone, and ICB compensation.
|
||||
|
||||
FNXC:EngineControls 2026-06-30-16:10:
|
||||
Browser verification showed the footer context can lose this low-specificity mobile override to later footer/card rules and collapse to the trigger's narrow corner. Keep a matching higher-specificity footer rule below so position:fixed and the full-width safe gutters win in the actual ExecutorStatusBar renderer.
|
||||
|
||||
FNXC:EngineControls 2026-06-30-16:25:
|
||||
Tablet browser verification at 820px still overflowed with the desktop anchored popover. Extend the viewport-safe panel breakpoint through common narrow tablets so the footer trigger cannot place a 24rem menu outside the visible inline bounds.
|
||||
|
||||
FNXC:EngineControls 2026-06-30-16:42:
|
||||
Browser verification at 1024px landscape showed the footer popover could still inherit the shared .card position and render off-screen. Keep the viewport-safe footer override active through that tablet width, and keep a footer-specific desktop rule outside the media query so .card cannot reset the anchored popover on wider screens.
|
||||
*/
|
||||
--engine-control-mobile-bottom-stack: calc(var(--executor-footer-height) + var(--mobile-nav-height) + max(env(safe-area-inset-bottom, 0px), var(--space-md)) + var(--standalone-bottom-gap) + var(--icb-bottom-offset, 0px));
|
||||
position: fixed;
|
||||
left: var(--space-sm);
|
||||
right: var(--space-sm);
|
||||
bottom: calc(var(--mobile-nav-height) + max(env(safe-area-inset-bottom, 0px), var(--space-md)) + var(--space-2xl));
|
||||
bottom: var(--engine-control-mobile-bottom-stack);
|
||||
width: auto;
|
||||
max-height: min(28rem, calc(100vh - var(--mobile-nav-height) - var(--space-2xl) - var(--space-lg)));
|
||||
max-height: min(28rem, calc(100dvh - var(--engine-control-mobile-bottom-stack) - (var(--space-md) * 2)));
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.executor-status-bar__segment--engine-controls .engine-control-menu > .engine-control-menu__popover.card {
|
||||
--engine-control-mobile-bottom-stack: calc(var(--executor-footer-height) + var(--mobile-nav-height) + max(env(safe-area-inset-bottom, 0px), var(--space-md)) + var(--standalone-bottom-gap) + var(--icb-bottom-offset, 0px));
|
||||
position: fixed;
|
||||
left: var(--space-sm);
|
||||
right: var(--space-sm);
|
||||
bottom: var(--engine-control-mobile-bottom-stack);
|
||||
width: auto;
|
||||
max-height: min(28rem, calc(100dvh - var(--engine-control-mobile-bottom-stack) - (var(--space-md) * 2)));
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.engine-control-menu__header {
|
||||
|
||||
@@ -10,6 +10,7 @@ const APP_DIR = resolve(__dirname, "..", "..");
|
||||
const COMPONENT_CSS = join(APP_DIR, "components", "EngineControlMenu.css");
|
||||
const STYLES_CSS = join(APP_DIR, "styles.css");
|
||||
const THEME_DATA_CSS = join(APP_DIR, "public", "theme-data.css");
|
||||
const RUNTIME_DEFINED_PROPERTIES = new Set(["--icb-bottom-offset"]);
|
||||
|
||||
function stripCssComments(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
@@ -56,9 +57,41 @@ function extractRuleBlock(css: string, selector: string): string {
|
||||
const ruleStart = css.indexOf(`${selector} {`);
|
||||
expect(ruleStart, `Expected ${selector} to exist in EngineControlMenu.css`).toBeGreaterThanOrEqual(0);
|
||||
const bodyStart = css.indexOf("{", ruleStart);
|
||||
const bodyEnd = css.indexOf("\n}", bodyStart);
|
||||
expect(bodyEnd, `Expected ${selector} rule to have a closing brace`).toBeGreaterThan(bodyStart);
|
||||
return css.slice(bodyStart + 1, bodyEnd);
|
||||
let braceCount = 1;
|
||||
let bodyEnd = bodyStart + 1;
|
||||
while (braceCount > 0 && bodyEnd < css.length) {
|
||||
if (css[bodyEnd] === "{") braceCount += 1;
|
||||
if (css[bodyEnd] === "}") braceCount -= 1;
|
||||
bodyEnd += 1;
|
||||
}
|
||||
expect(braceCount, `Expected ${selector} rule to have a closing brace`).toBe(0);
|
||||
return css.slice(bodyStart + 1, bodyEnd - 1);
|
||||
}
|
||||
|
||||
function extractMobileMediaBlocks(css: string): string {
|
||||
const blocks: string[] = [];
|
||||
const regex = /@media[^{}]*\(max-width:\s*1024px\)[^{]*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(css)) !== null) {
|
||||
const startIdx = match.index + match[0].length;
|
||||
let braceCount = 1;
|
||||
let endIdx = startIdx;
|
||||
while (braceCount > 0 && endIdx < css.length) {
|
||||
if (css[endIdx] === "{") braceCount += 1;
|
||||
if (css[endIdx] === "}") braceCount -= 1;
|
||||
endIdx += 1;
|
||||
}
|
||||
if (braceCount === 0) {
|
||||
blocks.push(css.slice(startIdx, endIdx - 1));
|
||||
}
|
||||
}
|
||||
|
||||
return blocks.join("\n");
|
||||
}
|
||||
|
||||
function normalizeCss(css: string): string {
|
||||
return css.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
describe("EngineControlMenu CSS token validity (FN-6862)", () => {
|
||||
@@ -89,11 +122,58 @@ describe("EngineControlMenu CSS token validity (FN-6862)", () => {
|
||||
it("references only defined dashboard custom properties", () => {
|
||||
const violations: string[] = [];
|
||||
for (const [name, lineNumbers] of collectReferencedProperties(componentCss)) {
|
||||
if (!defined.has(name)) {
|
||||
if (!defined.has(name) && !RUNTIME_DEFINED_PROPERTIES.has(name)) {
|
||||
violations.push(`${relative(APP_DIR, COMPONENT_CSS)}: var(${name}) at line(s) ${lineNumbers.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(violations, `Undefined CSS custom properties referenced in EngineControlMenu.css:\n${violations.join("\n")}`).toEqual([]);
|
||||
});
|
||||
|
||||
it("renders the mobile and narrow-tablet footer popover as a viewport-safe bottom panel", () => {
|
||||
expect(componentCss).toContain("@media (max-width: 1024px)");
|
||||
expect(componentCss).not.toContain("@media (max-width: 768px)");
|
||||
|
||||
const desktopPopoverBlock = normalizeCss(extractRuleBlock(componentCss, ".engine-control-menu__popover"));
|
||||
const footerDesktopPopoverBlock = normalizeCss(
|
||||
extractRuleBlock(componentCss, ".executor-status-bar__segment--engine-controls .engine-control-menu > .engine-control-menu__popover.card"),
|
||||
);
|
||||
const mobileCss = extractMobileMediaBlocks(componentCss);
|
||||
const mobilePopoverBlock = normalizeCss(extractRuleBlock(mobileCss, ".engine-control-menu__popover"));
|
||||
const footerSpecificMobilePopoverBlock = normalizeCss(
|
||||
extractRuleBlock(mobileCss, ".executor-status-bar__segment--engine-controls .engine-control-menu > .engine-control-menu__popover.card"),
|
||||
);
|
||||
|
||||
expect(desktopPopoverBlock).toContain("position: absolute");
|
||||
expect(desktopPopoverBlock).toContain("right: 0");
|
||||
expect(desktopPopoverBlock).toContain("width: min(24rem");
|
||||
expect(footerDesktopPopoverBlock).toContain("position: absolute");
|
||||
expect(footerDesktopPopoverBlock).toContain("right: 0");
|
||||
expect(footerDesktopPopoverBlock).toContain("bottom: calc(100% + var(--space-xs))");
|
||||
|
||||
for (const block of [mobilePopoverBlock, footerSpecificMobilePopoverBlock]) {
|
||||
expect(block).toContain("position: fixed");
|
||||
expect(block).toContain("left: var(--space-sm)");
|
||||
expect(block).toContain("right: var(--space-sm)");
|
||||
expect(block).toContain("width: auto");
|
||||
expect(block).toContain("overflow: auto");
|
||||
expect(block).toContain("max-height: min(");
|
||||
expect(block).toContain("var(--executor-footer-height)");
|
||||
expect(block).toContain("var(--mobile-nav-height)");
|
||||
expect(block).toContain("max(env(safe-area-inset-bottom, 0px), var(--space-md))");
|
||||
expect(block).toContain("var(--standalone-bottom-gap)");
|
||||
expect(block).toContain("var(--icb-bottom-offset, 0px)");
|
||||
expect(block).not.toContain("right: 0");
|
||||
expect(block).not.toContain("width: min(24rem");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the footer-specific mobile override after the base mobile panel rule", () => {
|
||||
const mobileCss = extractMobileMediaBlocks(componentCss);
|
||||
const baseRuleIndex = mobileCss.indexOf(".engine-control-menu__popover {");
|
||||
const footerRuleIndex = mobileCss.indexOf(".executor-status-bar__segment--engine-controls .engine-control-menu > .engine-control-menu__popover.card {");
|
||||
|
||||
expect(baseRuleIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(footerRuleIndex).toBeGreaterThan(baseRuleIndex);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user