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:
@@ -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 (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.
|
||||
- **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 (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 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.
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user