feat(FN-788): nest Scripts under Terminal in compact overflow menu
- Restructure Header.tsx to render Scripts as a submenu of Terminal in compact/tablet overflow menus - Add submenu toggle state, keyboard navigation, and click-outside-to-close behavior - Add CSS for nested submenu styling with slide-in animation and depth indicators - Update Header tests to verify Scripts nested under Terminal with toggle interaction - Update mobile-header-controls and tablet-header-controls tests for new menu hierarchy - Update README documentation to reflect the Terminal > Scripts submenu structure
This commit is contained in:
@@ -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 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), Terminal, Pause, and Stop buttons remain inline for immediate access.
|
||||
- **Tablet (769px–1024px)**: 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 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 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 (769px–1024px)**: 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.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -72,6 +72,13 @@ describe("mobile-header-controls.css", () => {
|
||||
expect(cssContent).toMatch(/\.mobile-overflow-item:hover/);
|
||||
});
|
||||
|
||||
it("has terminal submenu styles for nested scripts under terminal", () => {
|
||||
expect(cssContent).toContain(".mobile-overflow-group");
|
||||
expect(cssContent).toContain(".mobile-overflow-submenu");
|
||||
expect(cssContent).toContain(".mobile-overflow-subitem");
|
||||
expect(cssContent).toContain(".mobile-overflow-chevron");
|
||||
});
|
||||
|
||||
it("does not contain obsolete mobile header search wrap rules", () => {
|
||||
// The old @media (max-width: 640px) and @media (max-width: 480px)
|
||||
// header search rules should be removed
|
||||
|
||||
@@ -172,10 +172,18 @@ describe("tablet header controls", () => {
|
||||
expect(screen.getByText("Import from GitHub")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains terminal on tablet", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it("overflow menu contains terminal submenu items when expanded on tablet", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it("overflow menu contains scheduled tasks on tablet", () => {
|
||||
@@ -230,10 +238,11 @@ describe("tablet header controls", () => {
|
||||
expect(onOpenSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onToggleTerminal from overflow menu on tablet", () => {
|
||||
it("calls onToggleTerminal from terminal submenu 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"));
|
||||
expect(onToggleTerminal).toHaveBeenCalled();
|
||||
});
|
||||
@@ -278,6 +287,17 @@ describe("tablet header controls", () => {
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes terminal submenu on Escape without closing overflow menu on tablet", () => {
|
||||
renderTabletHeader({ onToggleTerminal: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByTestId("overflow-terminal-group-trigger"));
|
||||
expect(screen.getByTestId("overflow-terminal-btn")).toBeDefined();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
// Submenu closes but overflow menu stays open
|
||||
expect(screen.queryByTestId("overflow-terminal-btn")).toBeNull();
|
||||
expect(screen.getByRole("menu")).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Search on tablet ───────────────────────────────────────────
|
||||
|
||||
it("renders desktop-style search input on tablet (not mobile search trigger)", () => {
|
||||
|
||||
@@ -345,12 +345,51 @@ describe("Header", () => {
|
||||
expect(screen.queryByTitle("More header actions")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows terminal in overflow menu on mobile", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it("shows terminal submenu items when terminal group is expanded on mobile", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it("shows scripts in terminal submenu on mobile when onOpenScripts is provided", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it("does not show scripts 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();
|
||||
});
|
||||
|
||||
it("calls onToggleTerminal from terminal submenu 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"));
|
||||
expect(onToggleTerminal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onOpenScripts from terminal submenu on mobile", () => {
|
||||
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"));
|
||||
expect(onOpenScripts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows GitHub import in overflow menu on mobile", () => {
|
||||
renderHeader({}, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
@@ -368,28 +407,6 @@ describe("Header", () => {
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByText("Settings")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onToggleTerminal when overflow terminal button is clicked", () => {
|
||||
const onToggleTerminal = vi.fn();
|
||||
renderHeader({ onToggleTerminal }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByTestId("overflow-terminal-btn"));
|
||||
expect(onToggleTerminal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows scripts in overflow menu on mobile", () => {
|
||||
renderHeader({ onOpenScripts: noop }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-scripts-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onOpenScripts from mobile overflow menu", () => {
|
||||
const onOpenScripts = vi.fn();
|
||||
renderHeader({ onOpenScripts }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByTestId("overflow-scripts-btn"));
|
||||
expect(onOpenScripts).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("search functionality", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 } from "lucide-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 type { ProjectInfo } from "../api";
|
||||
import { ProjectSelector } from "./ProjectSelector";
|
||||
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
|
||||
@@ -125,10 +125,17 @@ export function Header({
|
||||
const isCompact = isMobile || isTablet;
|
||||
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
||||
const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false);
|
||||
const [isTerminalSubmenuOpen, setIsTerminalSubmenuOpen] = 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);
|
||||
|
||||
// Keep ref in sync with state
|
||||
useEffect(() => {
|
||||
terminalSubmenuOpenRef.current = isTerminalSubmenuOpen;
|
||||
}, [isTerminalSubmenuOpen]);
|
||||
|
||||
// Keep mobile search open if there's an active search query
|
||||
const shouldShowMobileSearch = isMobileSearchOpen || searchQuery.length > 0;
|
||||
@@ -156,6 +163,10 @@ export function Header({
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (terminalSubmenuOpenRef.current) {
|
||||
setIsTerminalSubmenuOpen(false);
|
||||
return;
|
||||
}
|
||||
setIsOverflowMenuOpen(false);
|
||||
setIsMobileSearchOpen(false);
|
||||
}
|
||||
@@ -183,6 +194,7 @@ export function Header({
|
||||
const handleOverflowAction = useCallback((callback?: () => void) => {
|
||||
if (callback) callback();
|
||||
setIsOverflowMenuOpen(false);
|
||||
setIsTerminalSubmenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleMobileSearchClose = useCallback(() => {
|
||||
@@ -535,26 +547,50 @@ export function Header({
|
||||
<GitHubLogo size={16} />
|
||||
<span>Import from GitHub</span>
|
||||
</button>
|
||||
{onOpenScripts && (
|
||||
<div
|
||||
className="mobile-overflow-group"
|
||||
data-testid="overflow-terminal-group"
|
||||
>
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenScripts)}
|
||||
className="mobile-overflow-item mobile-overflow-group-trigger"
|
||||
onClick={() => setIsTerminalSubmenuOpen((prev) => !prev)}
|
||||
role="menuitem"
|
||||
data-testid="overflow-scripts-btn"
|
||||
aria-expanded={isTerminalSubmenuOpen}
|
||||
aria-haspopup="menu"
|
||||
data-testid="overflow-terminal-group-trigger"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
<span>Scripts</span>
|
||||
<span>Terminal</span>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`mobile-overflow-chevron${isTerminalSubmenuOpen ? " mobile-overflow-chevron--open" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onToggleTerminal)}
|
||||
role="menuitem"
|
||||
data-testid="overflow-terminal-btn"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
<span>Open Terminal</span>
|
||||
</button>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenSchedules)}
|
||||
|
||||
@@ -526,7 +526,7 @@ describe("Header", () => {
|
||||
expect(screen.getByTitle("List view")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows terminal in overflow menu and pause controls inline on mobile", () => {
|
||||
it("shows terminal group in overflow menu and pause controls inline on mobile", () => {
|
||||
render(
|
||||
<Header
|
||||
onToggleTerminal={vi.fn()}
|
||||
@@ -536,7 +536,10 @@ describe("Header", () => {
|
||||
);
|
||||
// Terminal is in overflow menu on mobile, not inline
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByText("Open Terminal")).toBeDefined();
|
||||
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();
|
||||
// Pause/stop are always inline
|
||||
expect(screen.getByTitle("Pause scheduling")).toBeDefined();
|
||||
expect(screen.getByTitle("Stop AI engine")).toBeDefined();
|
||||
@@ -703,7 +706,9 @@ describe("Header", () => {
|
||||
const onOpenScripts = vi.fn();
|
||||
render(<Header onOpenSettings={vi.fn()} onOpenScripts={onOpenScripts} onRunScript={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByText("Scripts"));
|
||||
// Open the terminal submenu first
|
||||
fireEvent.click(screen.getByTestId("overflow-terminal-group-trigger"));
|
||||
fireEvent.click(screen.getByTestId("overflow-scripts-btn"));
|
||||
expect(onOpenScripts).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -638,6 +638,37 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Terminal submenu in overflow */
|
||||
.mobile-overflow-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-overflow-group-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-overflow-chevron {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-overflow-chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.mobile-overflow-submenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-left: var(--space-md);
|
||||
}
|
||||
|
||||
.mobile-overflow-subitem {
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
|
||||
Reference in New Issue
Block a user