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:
gsxdsm
2026-06-21 04:01:39 -07:00
parent 68c4053a85
commit 48c86d7ca7
16 changed files with 666 additions and 390 deletions

View File

@@ -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:

View File

@@ -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}

View File

@@ -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", () => {

View 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)));
}
}

View 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>
);
});

View File

@@ -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 {

View File

@@ -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>
);

View File

@@ -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 {

View File

@@ -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} />

View File

@@ -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");

View File

@@ -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");
});
});

View File

@@ -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" },

View File

@@ -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);

View File

@@ -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}

View File

@@ -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 }}

View File

@@ -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",