diff --git a/.changeset/fn-7273-mobile-engine-controls-menu.md b/.changeset/fn-7273-mobile-engine-controls-menu.md new file mode 100644 index 0000000000..b7e673d4f0 --- /dev/null +++ b/.changeset/fn-7273-mobile-engine-controls-menu.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index a0b38db25f..f758814a65 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1077,7 +1077,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. 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, 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 1690f5bb49..2b601d01f8 100644 --- a/packages/dashboard/app/components/EngineControlMenu.css +++ b/packages/dashboard/app/components/EngineControlMenu.css @@ -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 { diff --git a/packages/dashboard/app/components/__tests__/EngineControlMenu.css.test.ts b/packages/dashboard/app/components/__tests__/EngineControlMenu.css.test.ts index 6f5e9f76c8..8e148a0588 100644 --- a/packages/dashboard/app/components/__tests__/EngineControlMenu.css.test.ts +++ b/packages/dashboard/app/components/__tests__/EngineControlMenu.css.test.ts @@ -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); + }); });