feat(FN-796): convert compact Terminal overflow to split action with script submenu

- Replace single overflow terminal button with split-action dropdown showing quick-run scripts
- Add script submenu in header for launching predefined commands from compact view
- Add comprehensive regression tests for split-button behavior and quick-run script actions
- Update dashboard README to document the new split-action terminal overflow UI
- Adjust title summarization threshold and clean up related test/store changes
This commit is contained in:
gsxdsm
2026-04-03 18:29:09 -07:00
parent a08d60231f
commit 0c87c6ce9e
6 changed files with 406 additions and 81 deletions

View File

@@ -55,8 +55,8 @@ AI-guided interactive planning for creating well-specified tasks from high-level
### Responsive Header
The dashboard header adapts across three responsive tiers to remain usable without wrapping or dropping controls:
- **Mobile (≤768px)**: Lower-priority actions (GitHub Import, Planning, Settings, and optionally Usage) move into an accessible overflow menu triggered by a "More actions" button. The menu closes on outside click, Escape key, or after selecting an action. When multiple projects are registered, a dedicated "Switch Project" entry (building icon) appears in the overflow menu, distinct from the folder icon used for file browsing. The overflow menu groups related actions: Terminal is a parent item with a collapsible submenu containing "Open Terminal" and "Scripts" entries. The board search input collapses to an icon button; tapping it expands a focused search field that stays visible while a query is active. The project selector and back button are hidden to save space. View toggle (Board/List), Pause, and Stop buttons remain inline for immediate access.
- **Tablet (769px1024px)**: The header uses a compact layout that keeps the view toggle, search input, and both engine controls (Pause/Resume scheduling and Stop/Start AI engine) inline at all times. Lower-priority utility actions (GitHub Import, Planning, Settings, Usage) move into the overflow menu so the engine controls never disappear. The Terminal overflow item groups "Open Terminal" and "Scripts" under a collapsible submenu. The project selector and back button are hidden on tablet, but the overflow menu includes the same "Switch Project" entry when multiple projects are registered.
- **Mobile (≤768px)**: Lower-priority actions (GitHub Import, Planning, Settings, and optionally Usage) move into an accessible overflow menu triggered by a "More actions" button. The menu closes on outside click, Escape key, or after selecting an action. When multiple projects are registered, a dedicated "Switch Project" entry (building icon) appears in the overflow menu, distinct from the folder icon used for file browsing. The Terminal overflow item is a split action: the left/primary tap opens the terminal immediately, while the right-side chevron expands a nested submenu listing runnable scripts fetched from the project's script settings. Tapping a script entry runs it directly via the terminal; a "Manage Scripts" link at the bottom of the submenu opens the full script editor when available. The board search input collapses to an icon button; tapping it expands a focused search field that stays visible while a query is active. The project selector and back button are hidden to save space. View toggle (Board/List), Pause, and Stop buttons remain inline for immediate access.
- **Tablet (769px1024px)**: The header uses a compact layout that keeps the view toggle, search input, and both engine controls (Pause/Resume scheduling and Stop/Start AI engine) inline at all times. Lower-priority utility actions (GitHub Import, Planning, Settings, Usage) move into the overflow menu so the engine controls never disappear. The Terminal overflow item uses the same split-action pattern as mobile: primary tap opens terminal, chevron expands the scripts submenu with runnable entries and a "Manage Scripts…" link. The project selector and back button are hidden on tablet, but the overflow menu includes the same "Switch Project" entry when multiple projects are registered.
- **Desktop (>1024px)**: Full header with all controls and the project selector inline. No overflow menu.
- **Keyboard Accessible**: All controls across tiers expose proper ARIA attributes (aria-expanded, aria-haspopup, aria-label) and support keyboard navigation.

View File

@@ -1,7 +1,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { Header } from "../components/Header";
// Mock fetchScripts for overflow submenu
const mockFetchScripts = vi.fn();
vi.mock("../api", () => ({
fetchScripts: (...args: unknown[]) => mockFetchScripts(...args),
}));
/**
* Tablet header controls test suite.
*
@@ -66,6 +73,10 @@ function renderDesktopHeader(props = {}) {
}
describe("tablet header controls", () => {
beforeEach(() => {
mockFetchScripts.mockResolvedValue({});
});
afterEach(() => {
vi.restoreAllMocks();
});
@@ -175,15 +186,17 @@ describe("tablet header controls", () => {
it("overflow menu contains terminal group on tablet", () => {
renderTabletHeader({ onToggleTerminal: noop });
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-terminal-group-trigger")).toBeDefined();
expect(screen.getByTestId("overflow-terminal-primary-btn")).toBeDefined();
expect(screen.getByTestId("overflow-terminal-submenu-toggle")).toBeDefined();
});
it("overflow menu contains terminal submenu items when expanded on tablet", () => {
it("overflow menu contains terminal submenu scripts when expanded on tablet", async () => {
renderTabletHeader({ onToggleTerminal: noop, onOpenScripts: noop });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-group-trigger"));
expect(screen.getByTestId("overflow-terminal-btn")).toBeDefined();
expect(screen.getByTestId("overflow-scripts-btn")).toBeDefined();
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
});
it("overflow menu contains scheduled tasks on tablet", () => {
@@ -238,12 +251,11 @@ describe("tablet header controls", () => {
expect(onOpenSettings).toHaveBeenCalled();
});
it("calls onToggleTerminal from terminal submenu on tablet", () => {
it("calls onToggleTerminal from terminal primary button on tablet", () => {
const onToggleTerminal = vi.fn();
renderTabletHeader({ onToggleTerminal });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-group-trigger"));
fireEvent.click(screen.getByTestId("overflow-terminal-btn"));
fireEvent.click(screen.getByTestId("overflow-terminal-primary-btn"));
expect(onToggleTerminal).toHaveBeenCalled();
});
@@ -287,14 +299,16 @@ describe("tablet header controls", () => {
expect(screen.queryByRole("menu")).toBeNull();
});
it("closes terminal submenu on Escape without closing overflow menu on tablet", () => {
renderTabletHeader({ onToggleTerminal: noop });
it("closes terminal submenu on Escape without closing overflow menu on tablet", async () => {
renderTabletHeader({ onToggleTerminal: noop, onOpenScripts: noop });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-group-trigger"));
expect(screen.getByTestId("overflow-terminal-btn")).toBeDefined();
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
fireEvent.keyDown(document, { key: "Escape" });
// Submenu closes but overflow menu stays open
expect(screen.queryByTestId("overflow-terminal-btn")).toBeNull();
expect(screen.queryByTestId("overflow-scripts-manage")).toBeNull();
expect(screen.getByRole("menu")).toBeDefined();
});
@@ -391,6 +405,57 @@ describe("tablet header controls", () => {
});
});
// ── Split-action Terminal button regression tests ─────────────
describe("split-action terminal button on tablet", () => {
it("primary terminal button opens terminal directly on tablet", () => {
const onToggleTerminal = vi.fn();
renderTabletHeader({ onToggleTerminal });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-primary-btn"));
expect(onToggleTerminal).toHaveBeenCalled();
// Menu should close after action
expect(screen.queryByTestId("overflow-terminal-primary-btn")).toBeNull();
});
it("chevron toggle opens submenu without calling onToggleTerminal on tablet", () => {
const onToggleTerminal = vi.fn();
renderTabletHeader({ onToggleTerminal, onOpenScripts: noop });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
expect(onToggleTerminal).not.toHaveBeenCalled();
// Menu should still be open
expect(screen.getByTestId("overflow-terminal-primary-btn")).toBeDefined();
});
it("renders script entries in submenu when scripts are fetched", async () => {
mockFetchScripts.mockResolvedValue({ lint: "pnpm lint", build: "pnpm build" });
const onRunScript = vi.fn();
renderTabletHeader({ onToggleTerminal: noop, onRunScript, onOpenScripts: noop, projectId: "test-project" });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-lint")).toBeDefined();
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
});
});
it("clicking a script entry calls onRunScript and closes overflow on tablet", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
const onRunScript = vi.fn();
renderTabletHeader({ onToggleTerminal: noop, onRunScript, onOpenScripts: noop, projectId: "test-project" });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
});
fireEvent.click(screen.getByTestId("overflow-script-item-build"));
expect(onRunScript).toHaveBeenCalledWith("build", "pnpm build");
// Menu should close
expect(screen.queryByTestId("overflow-terminal-primary-btn")).toBeNull();
});
});
// ── Settings is the last overflow menu item ────────────────────
describe("overflow menu ordering on tablet", () => {

View File

@@ -1,7 +1,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { Header } from "./Header";
// Mock fetchScripts for overflow submenu
const mockFetchScripts = vi.fn();
vi.mock("../api", () => ({
fetchScripts: (...args: unknown[]) => mockFetchScripts(...args),
}));
const noop = () => {};
// Helper to mock mobile/tablet/desktop viewport
@@ -46,6 +53,11 @@ function renderHeader(props = {}, tier: ViewportTier = "desktop") {
}
describe("Header", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchScripts.mockResolvedValue({});
});
it("renders the logo and brand", () => {
renderHeader();
expect(screen.getByText("Fusion")).toBeDefined();
@@ -348,48 +360,173 @@ describe("Header", () => {
it("shows terminal group in overflow menu on mobile", () => {
renderHeader({ onToggleTerminal: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-terminal-group-trigger")).toBeDefined();
expect(screen.getByTestId("overflow-terminal-primary-btn")).toBeDefined();
expect(screen.getByTestId("overflow-terminal-submenu-toggle")).toBeDefined();
});
it("shows terminal submenu items when terminal group is expanded on mobile", () => {
it("shows terminal submenu items when terminal group is expanded on mobile", async () => {
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-group-trigger"));
expect(screen.getByTestId("overflow-terminal-btn")).toBeDefined();
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
});
it("shows scripts in terminal submenu on mobile when onOpenScripts is provided", () => {
it("shows scripts manage in terminal submenu on mobile when onOpenScripts is provided", async () => {
renderHeader({ onOpenScripts: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-group-trigger"));
expect(screen.getByTestId("overflow-scripts-btn")).toBeDefined();
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
});
it("does not show scripts in terminal submenu when onOpenScripts is undefined", () => {
it("does not show scripts manage in terminal submenu when onOpenScripts is undefined", () => {
renderHeader({ onToggleTerminal: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-group-trigger"));
expect(screen.queryByTestId("overflow-scripts-btn")).toBeNull();
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
expect(screen.queryByTestId("overflow-scripts-manage")).toBeNull();
});
it("calls onToggleTerminal from terminal submenu on mobile", () => {
it("calls onToggleTerminal from primary terminal button on mobile", () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-group-trigger"));
fireEvent.click(screen.getByTestId("overflow-terminal-btn"));
fireEvent.click(screen.getByTestId("overflow-terminal-primary-btn"));
expect(onToggleTerminal).toHaveBeenCalled();
});
it("calls onOpenScripts from terminal submenu on mobile", () => {
it("calls onOpenScripts from terminal submenu manage on mobile", async () => {
const onOpenScripts = vi.fn();
renderHeader({ onOpenScripts }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-group-trigger"));
fireEvent.click(screen.getByTestId("overflow-scripts-btn"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
fireEvent.click(screen.getByTestId("overflow-scripts-manage"));
expect(onOpenScripts).toHaveBeenCalled();
});
it("primary terminal button opens terminal directly without expanding submenu", () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
// Click primary button — should open terminal and NOT expand submenu
fireEvent.click(screen.getByTestId("overflow-terminal-primary-btn"));
expect(onToggleTerminal).toHaveBeenCalled();
// Overflow menu should close after action
expect(screen.queryByRole("menu")).toBeNull();
});
it("chevron toggle expands submenu without opening terminal", () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal, onOpenScripts: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
// Click chevron — should expand submenu but NOT call onToggleTerminal
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
expect(onToggleTerminal).not.toHaveBeenCalled();
// Overflow menu should still be open (check by primary button still being visible)
expect(screen.getByTestId("overflow-terminal-primary-btn")).toBeDefined();
});
it("renders one script item per fetched script in submenu", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build", test: "pnpm test" });
const onRunScript = vi.fn();
renderHeader({ onToggleTerminal: noop, onRunScript, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
expect(screen.getByTestId("overflow-script-item-test")).toBeDefined();
});
});
it("clicking a script entry calls onRunScript and closes overflow", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
const onRunScript = vi.fn();
renderHeader({ onToggleTerminal: noop, onRunScript, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
});
fireEvent.click(screen.getByTestId("overflow-script-item-build"));
expect(onRunScript).toHaveBeenCalledWith("build", "pnpm build");
// Overflow menu should close after running script
expect(screen.queryByRole("menu")).toBeNull();
});
it("does not render old overflow-scripts-btn item", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
renderHeader({ onToggleTerminal: noop, onRunScript: noop, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
});
// The old generic scripts button should not exist
expect(screen.queryByTestId("overflow-scripts-btn")).toBeNull();
// The old terminal submenu "Open Terminal" button should not exist
expect(screen.queryByTestId("overflow-terminal-btn")).toBeNull();
});
it("shows loading state while fetching scripts", () => {
mockFetchScripts.mockImplementation(() => new Promise(() => {}));
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
expect(screen.getByTestId("overflow-scripts-loading")).toBeDefined();
});
it("shows manage scripts link when no scripts are configured", async () => {
mockFetchScripts.mockResolvedValue({});
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
});
it("does not show manage scripts link when onOpenScripts is undefined", async () => {
mockFetchScripts.mockResolvedValue({});
renderHeader({ onToggleTerminal: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.queryByTestId("overflow-scripts-manage")).toBeNull();
});
});
it("handles missing onRunScript gracefully", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
});
// Clicking script without onRunScript should not throw
expect(() => {
fireEvent.click(screen.getByTestId("overflow-script-item-build"));
}).not.toThrow();
// Overflow menu should still close
expect(screen.queryByRole("menu")).toBeNull();
});
it("shows Manage Scripts after script entries when scripts exist", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
renderHeader({ onToggleTerminal: noop, onRunScript: noop, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
});
it("shows GitHub import in overflow menu on mobile", () => {
renderHeader({}, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft, Target, Building2, ChevronRight, FileCode } from "lucide-react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft, Target, Building2, ChevronRight, FileCode, Loader2 } from "lucide-react";
import type { ProjectInfo } from "../api";
import { fetchScripts } from "../api";
import { ProjectSelector } from "./ProjectSelector";
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
@@ -126,17 +127,53 @@ export function Header({
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false);
const [isTerminalSubmenuOpen, setIsTerminalSubmenuOpen] = useState(false);
const [overflowScripts, setOverflowScripts] = useState<Record<string, string>>({});
const [overflowScriptsLoading, setOverflowScriptsLoading] = useState(false);
const overflowButtonRef = useRef<HTMLButtonElement>(null);
const overflowMenuRef = useRef<HTMLDivElement>(null);
const mobileSearchRef = useRef<HTMLDivElement>(null);
const mobileSearchInputRef = useRef<HTMLInputElement>(null);
const terminalSubmenuOpenRef = useRef(false);
// Script entries sorted alphabetically for overflow submenu
const overflowScriptEntries = useMemo(() => {
return Object.entries(overflowScripts).sort(([a], [b]) => a.localeCompare(b));
}, [overflowScripts]);
// Keep ref in sync with state
useEffect(() => {
terminalSubmenuOpenRef.current = isTerminalSubmenuOpen;
}, [isTerminalSubmenuOpen]);
// Fetch scripts when terminal submenu opens in compact mode
useEffect(() => {
if (!isTerminalSubmenuOpen || !isCompact) return;
let cancelled = false;
setOverflowScriptsLoading(true);
fetchScripts(projectId)
.then((data) => {
if (!cancelled) {
setOverflowScripts(data);
}
})
.catch(() => {
if (!cancelled) {
setOverflowScripts({});
}
})
.finally(() => {
if (!cancelled) {
setOverflowScriptsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [isTerminalSubmenuOpen, isCompact, projectId]);
// Keep mobile search open if there's an active search query
const shouldShowMobileSearch = isMobileSearchOpen || searchQuery.length > 0;
@@ -551,42 +588,80 @@ export function Header({
className="mobile-overflow-group"
data-testid="overflow-terminal-group"
>
<button
className="mobile-overflow-item mobile-overflow-group-trigger"
onClick={() => setIsTerminalSubmenuOpen((prev) => !prev)}
role="menuitem"
aria-expanded={isTerminalSubmenuOpen}
aria-haspopup="menu"
data-testid="overflow-terminal-group-trigger"
>
<Terminal size={16} />
<span>Terminal</span>
<ChevronRight
size={14}
className={`mobile-overflow-chevron${isTerminalSubmenuOpen ? " mobile-overflow-chevron--open" : ""}`}
/>
</button>
<div className="mobile-overflow-split-row">
<button
className="mobile-overflow-item mobile-overflow-split-primary"
onClick={() => handleOverflowAction(onToggleTerminal)}
role="menuitem"
data-testid="overflow-terminal-primary-btn"
>
<Terminal size={16} />
<span>Terminal</span>
</button>
<button
className="mobile-overflow-split-toggle"
onClick={() => setIsTerminalSubmenuOpen((prev) => !prev)}
role="menuitem"
aria-expanded={isTerminalSubmenuOpen}
aria-haspopup="menu"
aria-label="Show scripts"
data-testid="overflow-terminal-submenu-toggle"
>
<ChevronRight
size={14}
className={`mobile-overflow-chevron${isTerminalSubmenuOpen ? " mobile-overflow-chevron--open" : ""}`}
/>
</button>
</div>
{isTerminalSubmenuOpen && (
<div className="mobile-overflow-submenu" role="menu" aria-label="Terminal submenu">
<button
className="mobile-overflow-item mobile-overflow-subitem"
onClick={() => handleOverflowAction(onToggleTerminal)}
role="menuitem"
data-testid="overflow-terminal-btn"
>
<Terminal size={16} />
<span>Open Terminal</span>
</button>
{onOpenScripts && (
<button
className="mobile-overflow-item mobile-overflow-subitem"
onClick={() => handleOverflowAction(onOpenScripts)}
role="menuitem"
data-testid="overflow-scripts-btn"
>
<FileCode size={16} />
<span>Scripts</span>
</button>
<div className="mobile-overflow-submenu" role="menu" aria-label="Scripts submenu">
{overflowScriptsLoading ? (
<div className="mobile-overflow-submenu-loading" data-testid="overflow-scripts-loading">
<Loader2 size={14} className="animate-spin" />
<span>Loading scripts</span>
</div>
) : overflowScriptEntries.length > 0 ? (
<>
{overflowScriptEntries.map(([name, command]) => (
<button
key={name}
className="mobile-overflow-item mobile-overflow-subitem"
onClick={() => {
if (onRunScript) onRunScript(name, command);
setIsOverflowMenuOpen(false);
setIsTerminalSubmenuOpen(false);
}}
role="menuitem"
data-testid={`overflow-script-item-${name}`}
>
<Play size={14} />
<span>{name}</span>
</button>
))}
{onOpenScripts && (
<button
className="mobile-overflow-item mobile-overflow-subitem mobile-overflow-subitem--manage"
onClick={() => handleOverflowAction(onOpenScripts)}
role="menuitem"
data-testid="overflow-scripts-manage"
>
<FileCode size={14} />
<span>Manage Scripts</span>
</button>
)}
</>
) : (
onOpenScripts && (
<button
className="mobile-overflow-item mobile-overflow-subitem"
onClick={() => handleOverflowAction(onOpenScripts)}
role="menuitem"
data-testid="overflow-scripts-manage"
>
<FileCode size={14} />
<span>No scripts add one</span>
</button>
)
)}
</div>
)}

View File

@@ -1,7 +1,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { Header } from "../Header";
// Mock fetchScripts for overflow submenu
const mockFetchScripts = vi.fn();
vi.mock("../api", () => ({
fetchScripts: (...args: unknown[]) => mockFetchScripts(...args),
}));
// Mock matchMedia for mobile/tablet/desktop viewport tests
type ViewportTier = "mobile" | "tablet" | "desktop";
@@ -30,6 +37,7 @@ describe("Header", () => {
beforeEach(() => {
// Default to desktop viewport
mockMatchMedia("desktop");
mockFetchScripts.mockResolvedValue({});
});
afterEach(() => {
@@ -536,10 +544,8 @@ describe("Header", () => {
);
// Terminal is in overflow menu on mobile, not inline
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-terminal-group-trigger")).toBeDefined();
// Expand submenu to see Open Terminal
fireEvent.click(screen.getByTestId("overflow-terminal-group-trigger"));
expect(screen.getByTestId("overflow-terminal-btn")).toBeDefined();
expect(screen.getByTestId("overflow-terminal-primary-btn")).toBeDefined();
expect(screen.getByTestId("overflow-terminal-submenu-toggle")).toBeDefined();
// Pause/stop are always inline
expect(screen.getByTitle("Pause scheduling")).toBeDefined();
expect(screen.getByTitle("Stop AI engine")).toBeDefined();
@@ -702,13 +708,16 @@ describe("Header", () => {
expect(onOpenWorkflowSteps).toHaveBeenCalledOnce();
});
it("scripts overflow menu item calls onOpenScripts when clicked", () => {
it("scripts overflow menu item calls onOpenScripts when clicked", async () => {
const onOpenScripts = vi.fn();
render(<Header onOpenSettings={vi.fn()} onOpenScripts={onOpenScripts} onRunScript={vi.fn()} />);
fireEvent.click(screen.getByTitle("More header actions"));
// Open the terminal submenu first
fireEvent.click(screen.getByTestId("overflow-terminal-group-trigger"));
fireEvent.click(screen.getByTestId("overflow-scripts-btn"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
fireEvent.click(screen.getByTestId("overflow-scripts-manage"));
expect(onOpenScripts).toHaveBeenCalledOnce();
});
});

View File

@@ -644,12 +644,35 @@ body {
flex-direction: column;
}
.mobile-overflow-group-trigger {
width: 100%;
.mobile-overflow-split-row {
display: flex;
flex-direction: row;
align-items: stretch;
}
.mobile-overflow-split-primary {
flex: 1;
min-width: 0;
}
.mobile-overflow-split-toggle {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 10px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
transition: background var(--transition-fast);
}
.mobile-overflow-split-toggle:hover {
background: var(--card);
}
.mobile-overflow-chevron {
margin-left: auto;
color: var(--text-muted);
transition: transform var(--transition-fast);
}
@@ -669,6 +692,22 @@ body {
padding-left: 28px;
}
.mobile-overflow-submenu-loading {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: 8px 12px 8px 28px;
color: var(--text-muted);
font-size: 12px;
}
.mobile-overflow-subitem--manage {
color: var(--text-muted);
border-top: 1px solid var(--border);
margin-top: 2px;
padding-top: 8px;
}
.logo {
font-size: 20px;
font-weight: 700;