FN-6844: move engine controls to footer status bar
Move engine stop, pause, and scheduler tuning controls into the executor footer status bar. - Add a footer EngineControlMenu popover with stop/start, pause/resume, and debounced concurrency/worktree sliders. - Remove the engine controls from the dashboard header and wire the footer running-state text to open the controls. - Update dashboard tests, English labels, and operator documentation for the footer control location. Files changed: docs/dashboard-guide.md | 4 + packages/dashboard/app/App.tsx | 7 - .../app/__tests__/tablet-header-controls.test.tsx | 21 +- .../dashboard/app/components/EngineControlMenu.css | 118 +++++++++ .../dashboard/app/components/EngineControlMenu.tsx | 292 +++++++++++++++++++++ .../dashboard/app/components/ExecutorStatusBar.css | 28 ++ .../dashboard/app/components/ExecutorStatusBar.tsx | 25 +- packages/dashboard/app/components/Header.css | 89 ------- packages/dashboard/app/components/Header.tsx | 81 +----- .../app/components/__tests__/App.test.tsx | 132 ---------- .../__tests__/EngineControlMenu.test.tsx | 145 ++++++++++ .../__tests__/ExecutorStatusBar.test.tsx | 40 +++ .../app/components/__tests__/Header.test.tsx | 57 +--- .../components/__tests__/MultiProjectFlow.test.tsx | 8 - .../app/components/__tests__/ResearchView.test.tsx | 4 - packages/i18n/locales/en/app.json | 5 +- 16 files changed, 666 insertions(+), 390 deletions(-) Fusion-Task-Id: FN-6844 Fusion-Task-Lineage: f0e17e3d-489e-483c-ba7d-a020c111231c
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = {}) {
|
||||
<Header
|
||||
onOpenSettings={noop}
|
||||
onOpenGitHubImport={noop}
|
||||
globalPaused={false}
|
||||
enginePaused={false}
|
||||
onToggleGlobalPause={noop}
|
||||
onToggleEnginePause={noop}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -63,10 +59,6 @@ function renderDesktopHeader(props = {}) {
|
||||
<Header
|
||||
onOpenSettings={noop}
|
||||
onOpenGitHubImport={noop}
|
||||
globalPaused={false}
|
||||
enginePaused={false}
|
||||
onToggleGlobalPause={noop}
|
||||
onToggleEnginePause={noop}
|
||||
{...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", () => {
|
||||
|
||||
118
packages/dashboard/app/components/EngineControlMenu.css
Normal file
118
packages/dashboard/app/components/EngineControlMenu.css
Normal file
@@ -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)));
|
||||
}
|
||||
}
|
||||
292
packages/dashboard/app/components/EngineControlMenu.tsx
Normal file
292
packages/dashboard/app/components/EngineControlMenu.tsx
Normal file
@@ -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<T> =
|
||||
| { 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<keyof ConcurrencyValues, { min: number; max: number }> = {
|
||||
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<EngineControlMenuHandle, EngineControlMenuProps>(function EngineControlMenu({ projectId }, ref) {
|
||||
const { t } = useTranslation("app");
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { globalPaused, enginePaused, toggleGlobalPause, toggleEnginePause, refresh } = useAppSettings(projectId);
|
||||
const [concurrencyState, setConcurrencyState] = useState<AsyncState<ConcurrencyValues>>({ 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 (
|
||||
<div className="engine-control-menu" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-icon engine-control-menu__trigger${open ? " btn-icon--active" : ""}`}
|
||||
onClick={toggleMenu}
|
||||
title={t("executor.engineControls", "Engine controls")}
|
||||
aria-label={t("executor.engineControls", "Engine controls")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
data-testid="engine-control-menu-trigger"
|
||||
>
|
||||
<SlidersHorizontal size={14} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="card engine-control-menu__popover" role="menu" aria-label={t("executor.engineControls", "Engine controls")} data-testid="engine-control-menu">
|
||||
<div className="engine-control-menu__section engine-control-menu__section--actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary engine-control-menu__action"
|
||||
onClick={() => void toggleGlobalPause()}
|
||||
role="menuitem"
|
||||
data-testid="engine-control-stop-btn"
|
||||
>
|
||||
{globalPaused ? <Play size={16} aria-hidden="true" /> : <Square size={16} aria-hidden="true" />}
|
||||
<span>{globalPaused ? t("header.startAiEngine", "Start AI Engine") : t("header.stopAiEngine", "Stop AI Engine")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary engine-control-menu__action"
|
||||
onClick={() => void toggleEnginePause()}
|
||||
role="menuitem"
|
||||
disabled={globalPaused}
|
||||
title={globalPaused ? t("executor.triageDisabledWhileStopped", "Start the AI engine before changing triage scheduling") : undefined}
|
||||
data-testid="engine-control-pause-triage-btn"
|
||||
>
|
||||
{enginePaused ? <Play size={16} aria-hidden="true" /> : <Pause size={16} aria-hidden="true" />}
|
||||
<span>{enginePaused ? t("header.resumeScheduling", "Resume scheduling") : t("header.pauseTriage", "Pause triage")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="engine-control-menu__section engine-control-menu__section--sliders">
|
||||
<div className="engine-control-menu__section-header">
|
||||
<span>{t("commandCenter.controls.concurrency.title", "Concurrency")}</span>
|
||||
<span className={`engine-control-menu__save-state engine-control-menu__save-state--${concurrencySaveState}`} aria-live="polite">
|
||||
{saveLabel}
|
||||
</span>
|
||||
</div>
|
||||
<label className="engine-control-menu__slider" htmlFor="engine-control-max-concurrent">
|
||||
<span className="engine-control-menu__slider-label">
|
||||
{t("commandCenter.controls.concurrency.maxConcurrent", "Max concurrent tasks")}
|
||||
<strong>{concurrencyValues.maxConcurrent}</strong>
|
||||
</span>
|
||||
<input
|
||||
id="engine-control-max-concurrent"
|
||||
className="engine-control-menu__range input"
|
||||
type="range"
|
||||
min={CONCURRENCY_SLIDER_LIMITS.maxConcurrent.min}
|
||||
max={getConcurrencySliderMax("maxConcurrent", concurrencyValues.maxConcurrent)}
|
||||
value={concurrencyValues.maxConcurrent}
|
||||
disabled={concurrencyState.status === "loading"}
|
||||
onChange={(event) => updateConcurrencyValue(
|
||||
"maxConcurrent",
|
||||
event.target.value,
|
||||
CONCURRENCY_SLIDER_LIMITS.maxConcurrent.min,
|
||||
getConcurrencySliderMax("maxConcurrent", concurrencyValues.maxConcurrent),
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<label className="engine-control-menu__slider" htmlFor="engine-control-max-triage-concurrent">
|
||||
<span className="engine-control-menu__slider-label">
|
||||
{t("commandCenter.controls.concurrency.maxTriageConcurrent", "Max triage concurrent")}
|
||||
<strong>{concurrencyValues.maxTriageConcurrent}</strong>
|
||||
</span>
|
||||
<input
|
||||
id="engine-control-max-triage-concurrent"
|
||||
className="engine-control-menu__range input"
|
||||
type="range"
|
||||
min={CONCURRENCY_SLIDER_LIMITS.maxTriageConcurrent.min}
|
||||
max={getConcurrencySliderMax("maxTriageConcurrent", concurrencyValues.maxTriageConcurrent)}
|
||||
value={concurrencyValues.maxTriageConcurrent}
|
||||
disabled={concurrencyState.status === "loading"}
|
||||
onChange={(event) => updateConcurrencyValue(
|
||||
"maxTriageConcurrent",
|
||||
event.target.value,
|
||||
CONCURRENCY_SLIDER_LIMITS.maxTriageConcurrent.min,
|
||||
getConcurrencySliderMax("maxTriageConcurrent", concurrencyValues.maxTriageConcurrent),
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<label className="engine-control-menu__slider" htmlFor="engine-control-max-worktrees">
|
||||
<span className="engine-control-menu__slider-label">
|
||||
{t("commandCenter.controls.concurrency.maxWorktrees", "Max worktrees")}
|
||||
<strong>{concurrencyValues.maxWorktrees}</strong>
|
||||
</span>
|
||||
<input
|
||||
id="engine-control-max-worktrees"
|
||||
className="engine-control-menu__range input"
|
||||
type="range"
|
||||
min={CONCURRENCY_SLIDER_LIMITS.maxWorktrees.min}
|
||||
max={getConcurrencySliderMax("maxWorktrees", concurrencyValues.maxWorktrees)}
|
||||
value={concurrencyValues.maxWorktrees}
|
||||
disabled={concurrencyState.status === "loading"}
|
||||
onChange={(event) => updateConcurrencyValue(
|
||||
"maxWorktrees",
|
||||
event.target.value,
|
||||
CONCURRENCY_SLIDER_LIMITS.maxWorktrees.min,
|
||||
getConcurrencySliderMax("maxWorktrees", concurrencyValues.maxWorktrees),
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
{concurrencyState.status === "error" ? <p className="engine-control-menu__error" role="alert">{concurrencyState.error}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<EngineControlMenuHandle>(null);
|
||||
|
||||
const stateDisplay = useMemo(() => getStateDisplay(stats.executorState, t), [stats.executorState, t]);
|
||||
|
||||
@@ -295,12 +297,21 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
{/* Separator */}
|
||||
<span className="executor-status-bar__divider" aria-hidden="true" />
|
||||
|
||||
{/* Executor state badge */}
|
||||
<div className="executor-status-bar__segment">
|
||||
<StateIcon size={12} style={{ color: stateDisplay.color }} aria-hidden="true" />
|
||||
<span className="executor-status-bar__state" style={{ color: stateDisplay.color }}>
|
||||
{stateDisplay.label}
|
||||
</span>
|
||||
{/* Executor state badge and engine controls */}
|
||||
<div className="executor-status-bar__segment executor-status-bar__segment--engine-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="executor-status-bar__state-trigger"
|
||||
onClick={() => engineControlMenuRef.current?.open()}
|
||||
aria-label={t("executor.openEngineControlsForState", "Open engine controls for {{state}} state", { state: stateDisplay.label })}
|
||||
data-testid="executor-state-engine-control-trigger"
|
||||
>
|
||||
<StateIcon size={12} style={{ color: stateDisplay.color }} aria-hidden="true" />
|
||||
<span className="executor-status-bar__state" style={{ color: stateDisplay.color }}>
|
||||
{stateDisplay.label}
|
||||
</span>
|
||||
</button>
|
||||
<EngineControlMenu ref={engineControlMenuRef} projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -387,90 +387,6 @@ non-notched devices, so this is a no-op there. Pair with viewport-fit=cover (ind
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Split-button for engine controls */
|
||||
.engine-control-split-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.engine-control-split-btn__main {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.engine-control-split-btn__chevron {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
min-width: 28px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.engine-control-split-btn__divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.engine-control-split-btn__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 160px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.engine-control-split-btn__menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.engine-control-split-btn__menu-item:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.engine-control-split-btn__menu-item:focus-visible {
|
||||
background: var(--card-hover);
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.engine-control-split-btn__menu-item:not(:disabled):hover {
|
||||
background: var(--card-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
|
||||
.engine-control-split-btn__menu-item--active {
|
||||
color: var(--triage);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.engine-control-split-btn__menu-item--active:not(:disabled):hover {
|
||||
color: var(--triage);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header badge for active sessions */
|
||||
.btn-icon--has-indicator {
|
||||
position: relative;
|
||||
@@ -1023,11 +939,6 @@ non-notched devices, so this is a no-op there. Pair with viewport-fit=cover (ind
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.engine-control-split-btn__main,
|
||||
.engine-control-split-btn__chevron {
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
/* Mobile header: collapsible search trigger */
|
||||
.mobile-search-trigger {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge } from "lucide-react";
|
||||
import { Settings, Play, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge } from "lucide-react";
|
||||
import "./Header.css";
|
||||
// ProjectSelector styles used by the imported standalone component.
|
||||
import "./ProjectSelector.css";
|
||||
@@ -87,10 +87,6 @@ export interface HeaderProps {
|
||||
onOpenTodos?: () => void;
|
||||
todosOpen?: boolean;
|
||||
todosEnabled?: boolean;
|
||||
globalPaused?: boolean;
|
||||
enginePaused?: boolean;
|
||||
onToggleGlobalPause?: () => void;
|
||||
onToggleEnginePause?: () => void;
|
||||
view?: TaskView;
|
||||
onChangeView?: (view: TaskView) => void;
|
||||
/** Whether to show the skills tab in the view toggle */
|
||||
@@ -154,10 +150,6 @@ export function Header({
|
||||
onOpenTodos,
|
||||
todosOpen,
|
||||
todosEnabled,
|
||||
globalPaused,
|
||||
enginePaused,
|
||||
onToggleGlobalPause,
|
||||
onToggleEnginePause,
|
||||
view = "board",
|
||||
onChangeView,
|
||||
showSkillsTab,
|
||||
@@ -210,7 +202,6 @@ export function Header({
|
||||
const [isMobileProjectSwitchOpen, setIsMobileProjectSwitchOpen] = useState(false);
|
||||
const [isViewOverflowOpen, setIsViewOverflowOpen] = useState(false);
|
||||
const [isDesktopOverflowOpen, setIsDesktopOverflowOpen] = useState(false);
|
||||
const [isEngineMenuOpen, setIsEngineMenuOpen] = useState(false);
|
||||
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
|
||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||
const [scriptsLoading, setScriptsLoading] = useState(false);
|
||||
@@ -229,7 +220,6 @@ export function Header({
|
||||
const mobileProjectSwitchRef = useRef<HTMLDivElement>(null);
|
||||
const viewOverflowRef = useRef<HTMLDivElement>(null);
|
||||
const viewOverflowTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const engineMenuRef = useRef<HTMLDivElement>(null);
|
||||
const scriptsSplitButtonRef = useRef<HTMLDivElement>(null);
|
||||
const scriptsChevronButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const scriptsMenuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -663,32 +653,6 @@ export function Header({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isMobileProjectSwitchOpen]);
|
||||
|
||||
// Close engine controls dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!isEngineMenuOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (engineMenuRef.current && !engineMenuRef.current.contains(e.target as Node)) {
|
||||
setIsEngineMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isEngineMenuOpen]);
|
||||
|
||||
// Close engine controls dropdown on Escape
|
||||
useEffect(() => {
|
||||
if (!isEngineMenuOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsEngineMenuOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isEngineMenuOpen]);
|
||||
|
||||
// Close view toggle overflow on outside click
|
||||
useEffect(() => {
|
||||
if (!isViewOverflowOpen) return;
|
||||
@@ -1555,48 +1519,7 @@ export function Header({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Engine control split-button: main=stop/start, chevron dropdown=pause triage */}
|
||||
<div className="engine-control-split-btn" ref={engineMenuRef}>
|
||||
<button
|
||||
className={`btn-icon engine-control-split-btn__main${globalPaused ? " btn-icon--stopped" : ""}`}
|
||||
onClick={onToggleGlobalPause}
|
||||
title={globalPaused ? t("header.startAiEngine", "Start AI engine") : t("header.stopAiEngine", "Stop AI engine")}
|
||||
data-testid="engine-control-main-btn"
|
||||
>
|
||||
{globalPaused ? <Play size={16} /> : <Square size={16} />}
|
||||
</button>
|
||||
<span className="engine-control-split-btn__divider" />
|
||||
<button
|
||||
className={`btn-icon engine-control-split-btn__chevron${isEngineMenuOpen ? " btn-icon--active" : ""}`}
|
||||
onClick={() => setIsEngineMenuOpen((prev) => !prev)}
|
||||
title={t("header.engineOptions", "Engine options")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isEngineMenuOpen}
|
||||
data-testid="engine-control-chevron-btn"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{isEngineMenuOpen && (
|
||||
<div className="engine-control-split-btn__menu" role="menu">
|
||||
<button
|
||||
className={`engine-control-split-btn__menu-item${enginePaused ? " engine-control-split-btn__menu-item--active" : ""}`}
|
||||
onClick={() => {
|
||||
onToggleEnginePause?.();
|
||||
setIsEngineMenuOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
title={enginePaused ? t("header.resumeScheduling", "Resume scheduling") : t("header.pauseTriage", "Pause triage")}
|
||||
disabled={!!globalPaused}
|
||||
data-testid="engine-control-pause-triage-btn"
|
||||
>
|
||||
{enginePaused ? <Play size={14} /> : <Pause size={14} />}
|
||||
<span>{enginePaused ? t("header.resumeScheduling", "Resume scheduling") : t("header.pauseTriage", "Pause triage")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings - always inline on desktop, placed after engine controls */}
|
||||
{/* Settings - always inline on desktop; engine controls now live in the footer status bar. */}
|
||||
{!isCompact && (
|
||||
<button className="btn-icon" onClick={onOpenSettings} title={t("header.settings", "Settings")}>
|
||||
<Settings size={16} />
|
||||
|
||||
@@ -1962,138 +1962,6 @@ describe("OnboardingResumeCard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("App global pause (hard stop)", () => {
|
||||
it("initializes global pause state from fetchSettings", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
globalPause: true,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
// When globally paused, the stop button should show "Start AI engine"
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Start AI engine")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Stop button when globalPause is false", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
globalPause: false,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("engine-control-main-btn")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles global pause state and calls updateSettings", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
globalPause: false,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
// Wait for initial render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("engine-control-main-btn")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click the stop button
|
||||
fireEvent.click(screen.getByTestId("engine-control-main-btn"));
|
||||
|
||||
// Should optimistically switch to "Start" state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Start AI engine")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Should call updateSettings with globalPause and manual reason
|
||||
expect(updateSettings).toHaveBeenCalledWith(
|
||||
{ globalPause: true, globalPauseReason: "manual" },
|
||||
"proj_123",
|
||||
);
|
||||
});
|
||||
|
||||
it("reverts global pause state on updateSettings failure", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
globalPause: false,
|
||||
});
|
||||
(updateSettings as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("engine-control-main-btn")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click the stop button — will fail
|
||||
fireEvent.click(screen.getByTestId("engine-control-main-btn"));
|
||||
|
||||
// Should revert back to "Stop" state after failure
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("engine-control-main-btn")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("App engine pause (soft pause)", () => {
|
||||
it("initializes engine pause state from fetchSettings", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
enginePaused: true,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("engine-control-chevron-btn")).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("engine-control-chevron-btn"));
|
||||
expect(screen.getByTitle("Resume scheduling")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Pause button when enginePaused is false", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
enginePaused: false,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("engine-control-chevron-btn")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles engine pause state and calls updateSettings", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
enginePaused: false,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("engine-control-chevron-btn")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click the pause button
|
||||
fireEvent.click(screen.getByTestId("engine-control-chevron-btn"));
|
||||
fireEvent.click(screen.getByTestId("engine-control-pause-triage-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSettings).toHaveBeenCalledWith({ enginePaused: true }, "proj_123");
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("engine-control-chevron-btn"));
|
||||
expect(screen.getByTitle("Resume scheduling")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("App view switching", () => {
|
||||
it("opens research view from overflow and persists view selection", async () => {
|
||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { EngineControlMenu } from "../EngineControlMenu";
|
||||
|
||||
const defaultSettings = {
|
||||
maxConcurrent: 2,
|
||||
maxTriageConcurrent: 1,
|
||||
maxWorktrees: 4,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
autoMerge: true,
|
||||
experimentalFeatures: {},
|
||||
};
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
fetchConfig: vi.fn(),
|
||||
fetchSettings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
const legacyMocks = vi.hoisted(() => ({
|
||||
fetchConfig: vi.fn(),
|
||||
fetchSettings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", () => apiMocks);
|
||||
vi.mock("../../api/legacy", () => legacyMocks);
|
||||
vi.mock("../../versionCheck", () => ({
|
||||
setAutoReloadEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
async function openMenu() {
|
||||
render(<EngineControlMenu projectId="proj_123" />);
|
||||
fireEvent.click(screen.getByTestId("engine-control-menu-trigger"));
|
||||
await screen.findByTestId("engine-control-menu");
|
||||
}
|
||||
|
||||
describe("EngineControlMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
apiMocks.fetchConfig.mockResolvedValue({ maxConcurrent: 2, rootDir: "/workspace/project" });
|
||||
apiMocks.fetchSettings.mockResolvedValue({ ...defaultSettings });
|
||||
apiMocks.updateSettings.mockResolvedValue({ ...defaultSettings });
|
||||
apiMocks.updateGlobalSettings.mockResolvedValue({});
|
||||
legacyMocks.fetchConfig.mockResolvedValue({ maxConcurrent: 2, rootDir: "/workspace/project" });
|
||||
legacyMocks.fetchSettings.mockResolvedValue({ ...defaultSettings });
|
||||
legacyMocks.updateSettings.mockResolvedValue({ ...defaultSettings });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stops and starts the global AI engine via settings", async () => {
|
||||
apiMocks.fetchSettings.mockResolvedValue({ ...defaultSettings, globalPause: false });
|
||||
await openMenu();
|
||||
|
||||
fireEvent.click(screen.getByTestId("engine-control-stop-btn"));
|
||||
|
||||
await waitFor(() => expect(apiMocks.updateSettings).toHaveBeenCalledWith(
|
||||
{ globalPause: true, globalPauseReason: "manual" },
|
||||
"proj_123",
|
||||
));
|
||||
});
|
||||
|
||||
it("starts the global AI engine when currently stopped", async () => {
|
||||
apiMocks.fetchSettings.mockResolvedValue({ ...defaultSettings, globalPause: true });
|
||||
await openMenu();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("engine-control-stop-btn")).toHaveTextContent(/start ai engine/i));
|
||||
fireEvent.click(screen.getByTestId("engine-control-stop-btn"));
|
||||
|
||||
await waitFor(() => expect(apiMocks.updateSettings).toHaveBeenCalledWith(
|
||||
{ globalPause: false, globalPauseReason: undefined },
|
||||
"proj_123",
|
||||
));
|
||||
});
|
||||
|
||||
it("pauses and resumes triage, and disables triage while globally stopped", async () => {
|
||||
apiMocks.fetchSettings.mockResolvedValue({ ...defaultSettings, enginePaused: false });
|
||||
await openMenu();
|
||||
|
||||
fireEvent.click(screen.getByTestId("engine-control-pause-triage-btn"));
|
||||
|
||||
await waitFor(() => expect(apiMocks.updateSettings).toHaveBeenCalledWith({ enginePaused: true }, "proj_123"));
|
||||
|
||||
vi.clearAllMocks();
|
||||
apiMocks.fetchConfig.mockResolvedValue({ maxConcurrent: 2, rootDir: "/workspace/project" });
|
||||
apiMocks.fetchSettings.mockResolvedValue({ ...defaultSettings, globalPause: true, enginePaused: true });
|
||||
legacyMocks.fetchConfig.mockResolvedValue({ maxConcurrent: 2, rootDir: "/workspace/project" });
|
||||
legacyMocks.fetchSettings.mockResolvedValue({ ...defaultSettings });
|
||||
render(<EngineControlMenu projectId="proj_123" />);
|
||||
fireEvent.click(screen.getAllByTestId("engine-control-menu-trigger")[1]);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByTestId("engine-control-pause-triage-btn")).toHaveLength(2));
|
||||
const pauseButton = screen.getAllByTestId("engine-control-pause-triage-btn")[1];
|
||||
expect(pauseButton).toBeDisabled();
|
||||
expect(pauseButton).toHaveTextContent(/resume scheduling/i);
|
||||
});
|
||||
|
||||
it("persists debounced concurrency and worktree slider changes and refreshes settings", async () => {
|
||||
legacyMocks.fetchSettings.mockResolvedValue({
|
||||
...defaultSettings,
|
||||
maxConcurrent: 12,
|
||||
maxTriageConcurrent: 3,
|
||||
maxWorktrees: 25,
|
||||
});
|
||||
await openMenu();
|
||||
|
||||
const maxConcurrent = await screen.findByLabelText(/max concurrent tasks/i);
|
||||
const maxTriage = screen.getByLabelText(/max triage concurrent/i);
|
||||
const maxWorktrees = screen.getByLabelText(/max worktrees/i);
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
expect(maxConcurrent).toHaveAttribute("max", "12");
|
||||
expect(maxConcurrent).toHaveValue("12");
|
||||
expect(maxWorktrees).toHaveAttribute("max", "25");
|
||||
expect(maxWorktrees).toHaveValue("25");
|
||||
|
||||
fireEvent.change(maxConcurrent, { target: { value: "9" } });
|
||||
fireEvent.change(maxTriage, { target: { value: "4" } });
|
||||
fireEvent.change(maxWorktrees, { target: { value: "8" } });
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
});
|
||||
|
||||
expect(legacyMocks.updateSettings).toHaveBeenCalledWith(
|
||||
{ maxConcurrent: 9, maxTriageConcurrent: 4, maxWorktrees: 8 },
|
||||
"proj_123",
|
||||
);
|
||||
expect(apiMocks.fetchSettings).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("renders a load error state without crashing", async () => {
|
||||
legacyMocks.fetchSettings.mockRejectedValue(new Error("settings unavailable"));
|
||||
await openMenu();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("settings unavailable");
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,26 @@ vi.mock("../../hooks/useExecutorStats", () => ({
|
||||
useExecutorStats: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../EngineControlMenu", async () => {
|
||||
const React = await import("react");
|
||||
return {
|
||||
EngineControlMenu: React.forwardRef(function MockEngineControlMenu(_props: unknown, ref) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
open: () => setOpen(true),
|
||||
close: () => setOpen(false),
|
||||
toggle: () => setOpen((current) => !current),
|
||||
}));
|
||||
return (
|
||||
<div>
|
||||
<button type="button" data-testid="engine-control-menu-trigger" onClick={() => setOpen((current) => !current)}>Engine controls</button>
|
||||
{open ? <div role="menu" data-testid="engine-control-menu">Engine menu</div> : null}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { useExecutorStats } from "../../hooks/useExecutorStats";
|
||||
import type { ExecutorStats } from "../../api";
|
||||
|
||||
@@ -178,6 +198,26 @@ describe("ExecutorStatusBar", () => {
|
||||
expect(stateElement).toHaveTextContent("Running");
|
||||
});
|
||||
|
||||
it("renders footer engine controls next to Running state and opens from the small trigger", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar.querySelector(".executor-status-bar__segment--engine-controls")).toHaveTextContent("Running");
|
||||
await user.click(screen.getByTestId("engine-control-menu-trigger"));
|
||||
|
||||
expect(screen.getByTestId("engine-control-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens footer engine controls from the executor state text", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
await user.click(screen.getByTestId("executor-state-engine-control-trigger"));
|
||||
|
||||
expect(screen.getByTestId("engine-control-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Paused state with paused executorState", () => {
|
||||
vi.mocked(mockUseExecutorStats).mockReturnValue({
|
||||
stats: { ...defaultStats, executorState: "paused" },
|
||||
|
||||
@@ -43,10 +43,6 @@ function renderHeader(props = {}, tier: ViewportTier = "desktop") {
|
||||
<Header
|
||||
onOpenSettings={noop}
|
||||
onOpenGitHubImport={noop}
|
||||
globalPaused={false}
|
||||
enginePaused={false}
|
||||
onToggleGlobalPause={noop}
|
||||
onToggleEnginePause={noop}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -591,42 +587,11 @@ describe("Header", () => {
|
||||
});
|
||||
|
||||
describe("pause controls", () => {
|
||||
it("renders engine control split-button", () => {
|
||||
it("does not render the retired header engine control affordance", () => {
|
||||
renderHeader();
|
||||
expect(screen.getByTestId("engine-control-main-btn")).toBeDefined();
|
||||
expect(screen.getByTestId("engine-control-chevron-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders pause triage option in dropdown", () => {
|
||||
renderHeader();
|
||||
fireEvent.click(screen.getByTestId("engine-control-chevron-btn"));
|
||||
expect(screen.getByTestId("engine-control-pause-triage-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onToggleEnginePause when pause triage item is clicked", () => {
|
||||
const onToggleEnginePause = vi.fn();
|
||||
renderHeader({ onToggleEnginePause });
|
||||
fireEvent.click(screen.getByTestId("engine-control-chevron-btn"));
|
||||
fireEvent.click(screen.getByTestId("engine-control-pause-triage-btn"));
|
||||
expect(onToggleEnginePause).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onToggleGlobalPause when main button is clicked", () => {
|
||||
const onToggleGlobalPause = vi.fn();
|
||||
renderHeader({ onToggleGlobalPause });
|
||||
fireEvent.click(screen.getByTestId("engine-control-main-btn"));
|
||||
expect(onToggleGlobalPause).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows resume text in dropdown when engine is paused", () => {
|
||||
renderHeader({ enginePaused: true });
|
||||
fireEvent.click(screen.getByTestId("engine-control-chevron-btn"));
|
||||
expect(screen.getByTitle("Resume scheduling")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows start AI engine title on main button when global is paused", () => {
|
||||
renderHeader({ globalPaused: true });
|
||||
expect(screen.getByTitle("Start AI engine")).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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1670,7 +1635,7 @@ describe("Header", () => {
|
||||
});
|
||||
|
||||
describe("action ordering", () => {
|
||||
it("Settings is the last inline action on desktop (after stop button)", () => {
|
||||
it("Settings is the last inline action on desktop after engine controls moved to the footer", () => {
|
||||
const { container } = renderHeader({
|
||||
onOpenUsage: noop,
|
||||
onOpenActivityLog: noop,
|
||||
@@ -1681,24 +1646,18 @@ describe("Header", () => {
|
||||
onRunScript: noop,
|
||||
}, "desktop");
|
||||
|
||||
// Get direct children of header-actions: top-level btn-icon buttons AND the split-button container
|
||||
// Get direct top-level header action buttons; engine controls now live in the footer status bar.
|
||||
const headerActions = container.querySelector(".header-actions")!;
|
||||
expect(headerActions.querySelector(".engine-control-split-btn")).toBeNull();
|
||||
const inlineItems = Array.from(
|
||||
headerActions.querySelectorAll<HTMLElement>(
|
||||
":scope > button.btn-icon, :scope > .engine-control-split-btn"
|
||||
)
|
||||
headerActions.querySelectorAll<HTMLElement>(":scope > button.btn-icon")
|
||||
);
|
||||
|
||||
const settingsIdx = inlineItems.findIndex(
|
||||
(el) => el instanceof HTMLButtonElement && el.title === "Settings"
|
||||
);
|
||||
const splitBtnIdx = inlineItems.findIndex((el) =>
|
||||
el.classList.contains("engine-control-split-btn")
|
||||
);
|
||||
|
||||
expect(settingsIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(splitBtnIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(settingsIdx).toBeGreaterThan(splitBtnIdx);
|
||||
|
||||
const itemsAfterSettings = inlineItems.slice(settingsIdx + 1);
|
||||
expect(itemsAfterSettings).toHaveLength(0);
|
||||
|
||||
@@ -144,10 +144,6 @@ describe("MultiProjectFlow", () => {
|
||||
<Header
|
||||
onOpenSettings={noop}
|
||||
onOpenGitHubImport={noop}
|
||||
globalPaused={false}
|
||||
enginePaused={false}
|
||||
onToggleGlobalPause={noop}
|
||||
onToggleEnginePause={noop}
|
||||
projects={[singleProject, otherProject]}
|
||||
currentProject={singleProject}
|
||||
onViewAllProjects={handleViewAllProjects}
|
||||
@@ -171,10 +167,6 @@ describe("MultiProjectFlow", () => {
|
||||
<Header
|
||||
onOpenSettings={noop}
|
||||
onOpenGitHubImport={noop}
|
||||
globalPaused={false}
|
||||
enginePaused={false}
|
||||
onToggleGlobalPause={noop}
|
||||
onToggleEnginePause={noop}
|
||||
projects={[singleProject]}
|
||||
currentProject={singleProject}
|
||||
onViewAllProjects={noop}
|
||||
|
||||
@@ -77,10 +77,6 @@ describe("Research navigation", () => {
|
||||
<Header
|
||||
onOpenSettings={vi.fn()}
|
||||
onOpenGitHubImport={vi.fn()}
|
||||
globalPaused={false}
|
||||
enginePaused={false}
|
||||
onToggleGlobalPause={vi.fn()}
|
||||
onToggleEnginePause={vi.fn()}
|
||||
view="board"
|
||||
onChangeView={onChangeView}
|
||||
experimentalFeatures={{ researchView: true }}
|
||||
|
||||
@@ -2267,6 +2267,7 @@
|
||||
"daysAgo_other": "{{count}}d ago",
|
||||
"escalated": "Escalated",
|
||||
"escalatedSuffix": " (escalated)",
|
||||
"engineControls": "Engine controls",
|
||||
"hideProjectDir": "Hide project directory",
|
||||
"hoursAgo_one": "{{count}}h ago",
|
||||
"hoursAgo_other": "{{count}}h ago",
|
||||
@@ -2278,6 +2279,7 @@
|
||||
"noActivity": "no activity",
|
||||
"overlapBottleneck_one": "{{status}} overlap bottleneck {{blockerId}}: {{count}} todo blocked via blockedBy (threshold {{threshold}})",
|
||||
"overlapBottleneck_other": "{{status}} overlap bottleneck {{blockerId}}: {{count}} todo blocked via blockedBy (threshold {{threshold}})",
|
||||
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||
"overlapQueue": "Overlap queue",
|
||||
"overlapSummary_one": "{{blockerId}} · {{count}} todo",
|
||||
"overlapSummary_other": "{{blockerId}} · {{count}} todo",
|
||||
@@ -2292,7 +2294,8 @@
|
||||
"status": "Executor status",
|
||||
"stuck": "Stuck",
|
||||
"temporary": "Temporary",
|
||||
"todoStatus": "todo"
|
||||
"todoStatus": "todo",
|
||||
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||
},
|
||||
"fileBrowser": {
|
||||
"back": "Back to file list",
|
||||
|
||||
Reference in New Issue
Block a user