diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index a4d79a0ebe..0b4959c8e7 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -820,6 +820,10 @@ Settings → Merge includes **Legacy auto-merge stamp cleanup** for operators au Use this panel when upgrading a project with pre-FN-6245/FN-6277 in-review rows before relying on per-task auto-merge overrides. It only targets stamps tagged as legacy provenance; explicit user overrides remain intact. +### 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. 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. + ### Identifying high-impact blockers Use blocker fan-out signals on task cards and in the footer status bar to spot blockers with high downstream impact: diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index fd2e515521..27404a7432 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -951,7 +951,6 @@ function AppInner() { maxConcurrent, autoMerge, globalPaused, - enginePaused, isTestMode, taskStuckTimeoutMs, staleHighFanoutBlockerAgeThresholdMs, @@ -968,8 +967,6 @@ function AppInner() { todosEnabled, goalsEnabled, toggleAutoMerge, - toggleGlobalPause, - toggleEnginePause, refresh: refreshAppSettings, } = useAppSettings(currentProject?.id); @@ -1949,10 +1946,6 @@ function AppInner() { onOpenTodos={openTodosWithNav} todosOpen={modalManager.todosOpen} todosEnabled={todosEnabled} - globalPaused={globalPaused} - enginePaused={enginePaused} - onToggleGlobalPause={toggleGlobalPause} - onToggleEnginePause={toggleEnginePause} view={taskView} onChangeView={viewMode === "project" && currentProject ? handleTaskViewChange : undefined} showSkillsTab={skillsEnabled} diff --git a/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx b/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx index fc41a357fd..65a7d4597a 100644 --- a/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx +++ b/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx @@ -13,8 +13,8 @@ vi.mock("../api", () => ({ * Tablet header controls test suite. * * Verifies that the tablet viewport tier (769px–1024px) renders the - * header with engine controls inline while moving lower-priority actions - * into the overflow menu. + * header without the retired engine controls while moving lower-priority + * actions into the overflow menu. */ type ViewportTier = "mobile" | "tablet" | "desktop"; @@ -48,10 +48,6 @@ function renderTabletHeader(props = {}) {
); @@ -63,10 +59,6 @@ function renderDesktopHeader(props = {}) {
); @@ -81,12 +73,13 @@ describe("tablet header controls", () => { vi.restoreAllMocks(); }); - // ── Engine controls stay inline on tablet ────────────────────── + // ── Engine controls moved out of the header ────────────────────── - it("renders engine control split-button inline on tablet", () => { + it("does not render engine control split-button inline on tablet", () => { renderTabletHeader(); - expect(screen.getByTestId("engine-control-main-btn")).toBeDefined(); - expect(screen.getByTestId("engine-control-chevron-btn")).toBeDefined(); + expect(screen.queryByTestId("engine-control-main-btn")).toBeNull(); + expect(screen.queryByTestId("engine-control-chevron-btn")).toBeNull(); + expect(screen.queryByTestId("engine-control-pause-triage-btn")).toBeNull(); }); it("renders view toggle inline on tablet", () => { diff --git a/packages/dashboard/app/components/EngineControlMenu.css b/packages/dashboard/app/components/EngineControlMenu.css new file mode 100644 index 0000000000..41be5c675f --- /dev/null +++ b/packages/dashboard/app/components/EngineControlMenu.css @@ -0,0 +1,118 @@ +.engine-control-menu { + position: relative; + display: inline-flex; + align-items: center; +} + +.engine-control-menu__trigger { + color: var(--text-muted); +} + +.engine-control-menu__trigger:hover { + color: var(--text); +} + +.engine-control-menu__popover { + position: absolute; + right: 0; + bottom: calc(100% + var(--space-xs)); + z-index: 70; + width: min(24rem, calc(100vw - (var(--space-lg) * 2))); + max-height: min(32rem, calc(100vh - var(--space-2xl))); + overflow: auto; + padding: var(--space-md); + display: flex; + flex-direction: column; + gap: var(--space-md); + background: var(--surface-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + color: var(--text); +} + +.engine-control-menu__section { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.engine-control-menu__section--actions { + gap: var(--space-xs); +} + +.engine-control-menu__action { + width: 100%; + justify-content: flex-start; + gap: var(--space-sm); +} + +.engine-control-menu__action:disabled { + opacity: var(--opacity-disabled); + cursor: not-allowed; +} + +.engine-control-menu__section-header, +.engine-control-menu__slider-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); +} + +.engine-control-menu__section-header { + color: var(--text); + font-weight: 600; +} + +.engine-control-menu__save-state { + color: var(--text-muted); + font-size: var(--font-size-xs); + font-weight: 500; +} + +.engine-control-menu__save-state--saving { + color: var(--color-warning); +} + +.engine-control-menu__save-state--saved { + color: var(--color-success); +} + +.engine-control-menu__save-state--error, +.engine-control-menu__error { + color: var(--color-error); +} + +.engine-control-menu__slider { + display: flex; + flex-direction: column; + gap: var(--space-xs); + color: var(--text-muted); +} + +.engine-control-menu__slider-label strong { + color: var(--text); + font-family: var(--font-mono); +} + +.engine-control-menu__range { + width: 100%; + accent-color: var(--color-primary); +} + +.engine-control-menu__error { + margin: 0; + font-size: var(--font-size-xs); +} + +@media (max-width: 768px) { + .engine-control-menu__popover { + 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)); + width: auto; + max-height: min(28rem, calc(100vh - var(--mobile-nav-height) - var(--space-3xl))); + } +} diff --git a/packages/dashboard/app/components/EngineControlMenu.tsx b/packages/dashboard/app/components/EngineControlMenu.tsx new file mode 100644 index 0000000000..80683b0708 --- /dev/null +++ b/packages/dashboard/app/components/EngineControlMenu.tsx @@ -0,0 +1,292 @@ +import "./EngineControlMenu.css"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { DEFAULT_PROJECT_SETTINGS } from "@fusion/core"; +import { Pause, Play, SlidersHorizontal, Square } from "lucide-react"; +import { fetchConfig, fetchSettings, updateSettings } from "../api/legacy"; +import { useAppSettings } from "../hooks/useAppSettings"; + +export interface EngineControlMenuHandle { + open: () => void; + close: () => void; + toggle: () => void; +} + +export interface EngineControlMenuProps { + projectId?: string; +} + +type AsyncState = + | { status: "idle" | "loading"; data: T | null; error: null } + | { status: "loaded"; data: T; error: null } + | { status: "error"; data: T | null; error: string }; + +type ConcurrencyValues = { + maxConcurrent: number; + maxTriageConcurrent: number; + maxWorktrees: number; +}; + +const CONCURRENCY_SAVE_DEBOUNCE_MS = 500; +const DEFAULT_CONCURRENCY_VALUES: ConcurrencyValues = { + maxConcurrent: DEFAULT_PROJECT_SETTINGS.maxConcurrent, + maxTriageConcurrent: DEFAULT_PROJECT_SETTINGS.maxTriageConcurrent, + maxWorktrees: DEFAULT_PROJECT_SETTINGS.maxWorktrees, +}; + +const CONCURRENCY_SLIDER_LIMITS: Record = { + maxConcurrent: { min: 1, max: 10 }, + maxTriageConcurrent: { min: 1, max: 10 }, + maxWorktrees: { min: 1, max: 20 }, +}; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function getConcurrencySliderMax(key: keyof ConcurrencyValues, value: number) { + return Math.max(CONCURRENCY_SLIDER_LIMITS[key].max, value); +} + +function getErrorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + +/* +FNXC:EngineControls 2026-06-21-00:00: +Engine stop/start, triage pause/resume, and live scheduler concurrency/worktree sliders moved from the Header split button into the footer status bar. Operators open this popover from the footer trigger or running-status text, and the sliders reuse the existing /api/settings debounce flow so no backend route is added for live scheduler tuning. +*/ +export const EngineControlMenu = forwardRef(function EngineControlMenu({ projectId }, ref) { + const { t } = useTranslation("app"); + const menuRef = useRef(null); + const [open, setOpen] = useState(false); + const { globalPaused, enginePaused, toggleGlobalPause, toggleEnginePause, refresh } = useAppSettings(projectId); + const [concurrencyState, setConcurrencyState] = useState>({ status: "idle", data: null, error: null }); + const [concurrencyDirty, setConcurrencyDirty] = useState(false); + const [concurrencySaveState, setConcurrencySaveState] = useState<"idle" | "saving" | "saved" | "error">("idle"); + + const closeMenu = useCallback(() => setOpen(false), []); + const openMenu = useCallback(() => setOpen(true), []); + const toggleMenu = useCallback(() => setOpen((current) => !current), []); + + useImperativeHandle(ref, () => ({ + open: openMenu, + close: closeMenu, + toggle: toggleMenu, + }), [closeMenu, openMenu, toggleMenu]); + + useEffect(() => { + if (!open) return; + + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setOpen(false); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setConcurrencyDirty(false); + setConcurrencySaveState("idle"); + setConcurrencyState({ status: "loading", data: null, error: null }); + void (async () => { + try { + const [config, settings] = await Promise.all([fetchConfig(projectId), fetchSettings(projectId)]); + if (!cancelled) { + setConcurrencyState({ + status: "loaded", + data: { + maxConcurrent: settings.maxConcurrent ?? config.maxConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxConcurrent, + maxTriageConcurrent: settings.maxTriageConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxTriageConcurrent, + maxWorktrees: settings.maxWorktrees ?? DEFAULT_CONCURRENCY_VALUES.maxWorktrees, + }, + error: null, + }); + } + } catch (error) { + if (!cancelled) { + setConcurrencyState({ + status: "error", + data: DEFAULT_CONCURRENCY_VALUES, + error: getErrorMessage(error, t("commandCenter.controls.concurrency.error", "Unable to load concurrency settings")), + }); + } + } + })(); + return () => { + cancelled = true; + }; + }, [open, projectId, t]); + + useEffect(() => { + if (!open || !concurrencyDirty || !concurrencyState.data) return; + const values = concurrencyState.data; + const timeoutId = setTimeout(() => { + setConcurrencySaveState("saving"); + void updateSettings(values, projectId) + .then(async () => { + await refresh(); + setConcurrencyDirty(false); + setConcurrencySaveState("saved"); + }) + .catch(() => { + setConcurrencySaveState("error"); + }); + }, CONCURRENCY_SAVE_DEBOUNCE_MS); + return () => clearTimeout(timeoutId); + }, [concurrencyDirty, concurrencyState.data, open, projectId, refresh]); + + const updateConcurrencyValue = (key: keyof ConcurrencyValues, rawValue: string, min: number, max: number) => { + const nextValue = clamp(Number(rawValue), min, max); + setConcurrencyState((current) => ({ + status: "loaded", + data: { ...(current.data ?? DEFAULT_CONCURRENCY_VALUES), [key]: nextValue }, + error: null, + })); + setConcurrencyDirty(true); + setConcurrencySaveState("idle"); + }; + + const concurrencyValues = concurrencyState.data ?? DEFAULT_CONCURRENCY_VALUES; + const saveLabel = concurrencyState.status === "loading" + ? t("commandCenter.controls.status.loading", "Loading…") + : concurrencySaveState === "saving" + ? t("commandCenter.controls.status.saving", "Saving…") + : concurrencySaveState === "saved" + ? t("commandCenter.controls.status.saved", "Saved") + : concurrencySaveState === "error" + ? t("commandCenter.controls.status.saveError", "Save failed") + : t("commandCenter.controls.status.ready", "Ready"); + + return ( +
+ + + {open && ( +
+
+ + +
+ +
+
+ {t("commandCenter.controls.concurrency.title", "Concurrency")} + + {saveLabel} + +
+ + + + {concurrencyState.status === "error" ?

{concurrencyState.error}

: null} +
+
+ )} +
+ ); +}); diff --git a/packages/dashboard/app/components/ExecutorStatusBar.css b/packages/dashboard/app/components/ExecutorStatusBar.css index e683c38f40..de24a545a4 100644 --- a/packages/dashboard/app/components/ExecutorStatusBar.css +++ b/packages/dashboard/app/components/ExecutorStatusBar.css @@ -259,6 +259,34 @@ font-weight: 600; } +.executor-status-bar__segment--engine-controls { + position: relative; +} + +.executor-status-bar__state-trigger { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: 0; + border: none; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + line-height: 1; +} + +.executor-status-bar__state-trigger:hover .executor-status-bar__state { + text-decoration: underline; + text-underline-offset: calc(var(--space-xs) / 2); +} + +.executor-status-bar__state-trigger:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); + border-radius: var(--radius-sm); +} + /* Error message */ .executor-status-bar__error, .executor-status-bar__connecting { diff --git a/packages/dashboard/app/components/ExecutorStatusBar.tsx b/packages/dashboard/app/components/ExecutorStatusBar.tsx index 22439ca58e..4ccf4ef8d0 100644 --- a/packages/dashboard/app/components/ExecutorStatusBar.tsx +++ b/packages/dashboard/app/components/ExecutorStatusBar.tsx @@ -1,5 +1,5 @@ import "./ExecutorStatusBar.css"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { @@ -13,6 +13,7 @@ import { useExecutorStats } from "../hooks/useExecutorStats"; import { isLikelyTabSuspensionError } from "../hooks/visibilitySuspension"; import type { ExecutorState, AiSessionSummary } from "../api"; import { BackgroundTasksIndicator } from "./BackgroundTasksIndicator"; +import { EngineControlMenu, type EngineControlMenuHandle } from "./EngineControlMenu"; interface ExecutorStatusBarProps { /** Task list (shared with the board to keep counts in sync) */ @@ -94,6 +95,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH const { t } = useTranslation("app"); const { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs, lastFetchTimeMs); const [isProjectPathVisible, setIsProjectPathVisible] = useState(false); + const engineControlMenuRef = useRef(null); const stateDisplay = useMemo(() => getStateDisplay(stats.executorState, t), [stats.executorState, t]); @@ -295,12 +297,21 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH {/* Separator */}