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:
Fusion
2026-04-26 00:29:19 -07:00
committed by gsxdsm
parent cddf7bbd64
commit 82cd58fe13
31 changed files with 307 additions and 99 deletions

View File

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

View File

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

View File

@@ -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 && (

View File

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

View File

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