feat(FN-2579): merge fusion/fn-2579 (auto-resolved)
- fix(FN-2579): complete Step 3 — refine split-button accessibility - fix(FN-2579): resolve workspace lint regression in provider - test(FN-2579): complete Step 2 — update header engine control tests - feat(FN-2579): complete Step 1 — style engine split-button dropdown - feat(FN-2579): complete Step 0 — add engine control split-button behavior - chore(release): v1.0.0
This commit is contained in:
@@ -83,14 +83,10 @@ describe("tablet header controls", () => {
|
||||
|
||||
// ── Engine controls stay inline on tablet ──────────────────────
|
||||
|
||||
it("renders pause scheduling button inline on tablet", () => {
|
||||
it("renders engine control split-button inline on tablet", () => {
|
||||
renderTabletHeader();
|
||||
expect(screen.getByTitle("Pause scheduling")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders stop AI engine button inline on tablet", () => {
|
||||
renderTabletHeader();
|
||||
expect(screen.getByTitle("Stop AI engine")).toBeDefined();
|
||||
expect(screen.getByTestId("engine-control-main-btn")).toBeDefined();
|
||||
expect(screen.getByTestId("engine-control-chevron-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders view toggle inline on tablet", () => {
|
||||
|
||||
@@ -285,6 +285,90 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@@ -857,6 +941,12 @@
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
@@ -272,6 +272,7 @@ export function Header({
|
||||
const [isMobileProjectSwitchOpen, setIsMobileProjectSwitchOpen] = useState(false);
|
||||
const [isViewOverflowOpen, setIsViewOverflowOpen] = useState(false);
|
||||
const [isDesktopOverflowOpen, setIsDesktopOverflowOpen] = useState(false);
|
||||
const [isEngineMenuOpen, setIsEngineMenuOpen] = useState(false);
|
||||
const [overflowScripts, setOverflowScripts] = useState<Record<string, string>>({});
|
||||
const [overflowScriptsLoading, setOverflowScriptsLoading] = useState(false);
|
||||
const overflowButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -285,6 +286,7 @@ export function Header({
|
||||
const mobileProjectSwitchRef = useRef<HTMLDivElement>(null);
|
||||
const viewOverflowRef = useRef<HTMLDivElement>(null);
|
||||
const viewOverflowTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const engineMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get remote nodes only (exclude local node type)
|
||||
const remoteNodes = useMemo(() =>
|
||||
@@ -451,6 +453,32 @@ 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;
|
||||
@@ -1058,24 +1086,46 @@ export function Header({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pause button (soft pause) - always inline */}
|
||||
<button
|
||||
className={`btn-icon${enginePaused ? " btn-icon--paused" : ""}`}
|
||||
onClick={onToggleEnginePause}
|
||||
title={enginePaused ? "Resume scheduling" : "Pause scheduling"}
|
||||
disabled={!!globalPaused}
|
||||
>
|
||||
{enginePaused ? <Play size={16} /> : <Pause size={16} />}
|
||||
</button>
|
||||
|
||||
{/* Stop button (hard stop) - always inline */}
|
||||
<button
|
||||
className={`btn-icon${globalPaused ? " btn-icon--stopped" : ""}`}
|
||||
onClick={onToggleGlobalPause}
|
||||
title={globalPaused ? "Start AI engine" : "Stop AI engine"}
|
||||
>
|
||||
{globalPaused ? <Play size={16} /> : <Square size={16} />}
|
||||
</button>
|
||||
{/* 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 ? "Start AI engine" : "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="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 ? "Resume scheduling" : "Pause triage"}
|
||||
disabled={!!globalPaused}
|
||||
data-testid="engine-control-pause-triage-btn"
|
||||
>
|
||||
{enginePaused ? <Play size={14} /> : <Pause size={14} />}
|
||||
<span>{enginePaused ? "Resume scheduling" : "Pause triage"}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings - always inline on desktop, placed after engine controls */}
|
||||
{!isCompact && (
|
||||
|
||||
@@ -1018,7 +1018,7 @@ describe("App global pause (hard stop)", () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Stop AI engine")).toBeTruthy();
|
||||
expect(screen.getByTestId("engine-control-main-btn")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1032,11 +1032,11 @@ describe("App global pause (hard stop)", () => {
|
||||
|
||||
// Wait for initial render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Stop AI engine")).toBeTruthy();
|
||||
expect(screen.getByTestId("engine-control-main-btn")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click the stop button
|
||||
fireEvent.click(screen.getByTitle("Stop AI engine"));
|
||||
fireEvent.click(screen.getByTestId("engine-control-main-btn"));
|
||||
|
||||
// Should optimistically switch to "Start" state
|
||||
await waitFor(() => {
|
||||
@@ -1060,15 +1060,15 @@ describe("App global pause (hard stop)", () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Stop AI engine")).toBeTruthy();
|
||||
expect(screen.getByTestId("engine-control-main-btn")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click the stop button — will fail
|
||||
fireEvent.click(screen.getByTitle("Stop AI engine"));
|
||||
fireEvent.click(screen.getByTestId("engine-control-main-btn"));
|
||||
|
||||
// Should revert back to "Stop" state after failure
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Stop AI engine")).toBeTruthy();
|
||||
expect(screen.getByTestId("engine-control-main-btn")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1082,10 +1082,11 @@ describe("App engine pause (soft pause)", () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
// When engine is paused, the pause button should show "Resume scheduling"
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Resume scheduling")).toBeTruthy();
|
||||
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 () => {
|
||||
@@ -1097,7 +1098,7 @@ describe("App engine pause (soft pause)", () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Pause scheduling")).toBeTruthy();
|
||||
expect(screen.getByTestId("engine-control-chevron-btn")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1110,19 +1111,18 @@ describe("App engine pause (soft pause)", () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Pause scheduling")).toBeTruthy();
|
||||
expect(screen.getByTestId("engine-control-chevron-btn")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click the pause button
|
||||
fireEvent.click(screen.getByTitle("Pause scheduling"));
|
||||
fireEvent.click(screen.getByTestId("engine-control-chevron-btn"));
|
||||
fireEvent.click(screen.getByTestId("engine-control-pause-triage-btn"));
|
||||
|
||||
// Should optimistically switch to "Resume" state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Resume scheduling")).toBeTruthy();
|
||||
expect(updateSettings).toHaveBeenCalledWith({ enginePaused: true }, "proj_123");
|
||||
});
|
||||
|
||||
// Should call updateSettings with enginePaused: true
|
||||
expect(updateSettings).toHaveBeenCalledWith({ enginePaused: true }, "proj_123");
|
||||
fireEvent.click(screen.getByTestId("engine-control-chevron-btn"));
|
||||
expect(screen.getByTitle("Resume scheduling")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -223,36 +223,40 @@ describe("Header", () => {
|
||||
});
|
||||
|
||||
describe("pause controls", () => {
|
||||
it("renders pause button for engine pause", () => {
|
||||
it("renders engine control split-button", () => {
|
||||
renderHeader();
|
||||
expect(screen.getByTitle("Pause scheduling")).toBeDefined();
|
||||
expect(screen.getByTestId("engine-control-main-btn")).toBeDefined();
|
||||
expect(screen.getByTestId("engine-control-chevron-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders stop button for global pause", () => {
|
||||
it("renders pause triage option in dropdown", () => {
|
||||
renderHeader();
|
||||
expect(screen.getByTitle("Stop AI engine")).toBeDefined();
|
||||
fireEvent.click(screen.getByTestId("engine-control-chevron-btn"));
|
||||
expect(screen.getByTestId("engine-control-pause-triage-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onToggleEnginePause when pause button is clicked", () => {
|
||||
it("calls onToggleEnginePause when pause triage item is clicked", () => {
|
||||
const onToggleEnginePause = vi.fn();
|
||||
renderHeader({ onToggleEnginePause });
|
||||
fireEvent.click(screen.getByTitle("Pause scheduling"));
|
||||
fireEvent.click(screen.getByTestId("engine-control-chevron-btn"));
|
||||
fireEvent.click(screen.getByTestId("engine-control-pause-triage-btn"));
|
||||
expect(onToggleEnginePause).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onToggleGlobalPause when stop button is clicked", () => {
|
||||
it("calls onToggleGlobalPause when main button is clicked", () => {
|
||||
const onToggleGlobalPause = vi.fn();
|
||||
renderHeader({ onToggleGlobalPause });
|
||||
fireEvent.click(screen.getByTitle("Stop AI engine"));
|
||||
fireEvent.click(screen.getByTestId("engine-control-main-btn"));
|
||||
expect(onToggleGlobalPause).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows resume text when engine is paused", () => {
|
||||
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 text when global is paused", () => {
|
||||
it("shows start AI engine title on main button when global is paused", () => {
|
||||
renderHeader({ globalPaused: true });
|
||||
expect(screen.getByTitle("Start AI engine")).toBeDefined();
|
||||
});
|
||||
@@ -1171,25 +1175,27 @@ describe("Header", () => {
|
||||
onRunScript: noop,
|
||||
}, "desktop");
|
||||
|
||||
// Get all inline btn-icon buttons inside header-actions
|
||||
// Get direct children of header-actions: top-level btn-icon buttons AND the split-button container
|
||||
const headerActions = container.querySelector(".header-actions")!;
|
||||
const inlineButtons = Array.from(headerActions.querySelectorAll<HTMLButtonElement>(":scope > button.btn-icon"));
|
||||
const inlineItems = Array.from(
|
||||
headerActions.querySelectorAll<HTMLElement>(
|
||||
":scope > button.btn-icon, :scope > .engine-control-split-btn"
|
||||
)
|
||||
);
|
||||
|
||||
// Find the Settings button index and the Pause/Stop button indices
|
||||
const settingsIdx = inlineButtons.findIndex((btn) => btn.title === "Settings");
|
||||
const pauseIdx = inlineButtons.findIndex((btn) => btn.title === "Pause scheduling" || btn.title === "Resume scheduling");
|
||||
const stopIdx = inlineButtons.findIndex((btn) => btn.title === "Stop AI engine" || btn.title === "Start AI engine");
|
||||
const settingsIdx = inlineItems.findIndex(
|
||||
(el) => el instanceof HTMLButtonElement && el.title === "Settings"
|
||||
);
|
||||
const splitBtnIdx = inlineItems.findIndex((el) =>
|
||||
el.classList.contains("engine-control-split-btn")
|
||||
);
|
||||
|
||||
// Settings must exist
|
||||
expect(settingsIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(splitBtnIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(settingsIdx).toBeGreaterThan(splitBtnIdx);
|
||||
|
||||
// Settings must come after pause and stop (engine controls come before Settings)
|
||||
expect(settingsIdx).toBeGreaterThan(pauseIdx);
|
||||
expect(settingsIdx).toBeGreaterThan(stopIdx);
|
||||
|
||||
// Settings must be the very last button — no buttons after it
|
||||
const buttonsAfterSettings = inlineButtons.slice(settingsIdx + 1);
|
||||
expect(buttonsAfterSettings).toHaveLength(0);
|
||||
const itemsAfterSettings = inlineItems.slice(settingsIdx + 1);
|
||||
expect(itemsAfterSettings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("Settings is the last item in the mobile overflow menu", () => {
|
||||
|
||||
Reference in New Issue
Block a user