feat(KB-133): add mobile-responsive header with collapsible controls

- Add mobile-only header with collapsible search and overflow menu interactions

- Implement responsive layout locks in CSS for all screen sizes

- Update Header component with touch-friendly interaction states

- Add comprehensive unit and integration tests for mobile controls

- Document mobile header behavior and responsive design in README
This commit is contained in:
gsxdsm
2026-03-30 08:31:29 -07:00
parent f7091c9a40
commit e35c745fc2
5 changed files with 643 additions and 51 deletions

View File

@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
/**
* Stylesheet regression test for mobile header controls.
*
* This test verifies that the CSS file contains the necessary mobile
* header selectors and rules for:
* - Collapsed search trigger (.mobile-search-trigger)
* - Expanded mobile search panel (.mobile-search-expanded)
* - Overflow menu trigger (.mobile-overflow-trigger)
* - Overflow menu popover (.mobile-overflow-menu, .mobile-overflow-item)
*
* The mobile styles are located within @media (max-width: 768px) blocks.
*/
describe("mobile-header-controls.css", () => {
const cssPath = resolve(__dirname, "../styles.css");
const cssContent = readFileSync(cssPath, "utf-8");
// Extract all content from @media (max-width: 768px) blocks
// This is a simplified approach - we find all mobile media blocks and join them
function extractMobileMediaBlocks(content: string): string {
const blocks: string[] = [];
const regex = /@media\s*\(\s*max-width:\s*768px\s*\)\s*\{/g;
let match;
while ((match = regex.exec(content)) !== null) {
const startIdx = match.index + match[0].length;
// Find the matching closing brace by counting braces
let braceCount = 1;
let endIdx = startIdx;
while (braceCount > 0 && endIdx < content.length) {
if (content[endIdx] === "{") braceCount++;
if (content[endIdx] === "}") braceCount--;
endIdx++;
}
if (braceCount === 0) {
blocks.push(content.slice(startIdx, endIdx - 1));
}
}
return blocks.join("\n");
}
const mobileCss = extractMobileMediaBlocks(cssContent);
it("contains at least one @media (max-width: 768px) block", () => {
expect(mobileCss.length).toBeGreaterThan(0);
});
it("has mobile search trigger styles", () => {
expect(mobileCss).toContain(".mobile-search-trigger");
});
it("has mobile search expanded panel styles", () => {
expect(mobileCss).toContain(".mobile-search-expanded");
});
it("has mobile overflow trigger styles", () => {
expect(mobileCss).toContain(".mobile-overflow-trigger");
});
it("has mobile overflow menu styles", () => {
expect(mobileCss).toContain(".mobile-overflow-menu");
expect(mobileCss).toContain(".mobile-overflow-item");
});
it("has mobile overflow menu item hover states", () => {
expect(mobileCss).toMatch(/\.mobile-overflow-item:hover/);
});
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
const removedPatterns = [
/@media\s*\(\s*max-width:\s*640px\s*\)\s*\{[^}]*\.header-search/s,
/@media\s*\(\s*max-width:\s*480px\s*\)\s*\{[^}]*\.header-search/s,
];
for (const pattern of removedPatterns) {
expect(cssContent).not.toMatch(pattern);
}
});
it("has position relative on header-actions for absolute positioning", () => {
// Find the .header-actions rule and check for position: relative
const headerActionsMatch = cssContent.match(/\.header-actions\s*\{([^}]+)\}/);
expect(headerActionsMatch).toBeTruthy();
if (headerActionsMatch) {
expect(headerActionsMatch[1]).toContain("position: relative");
}
});
});

View File

@@ -1,4 +1,5 @@
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity } from "lucide-react";
import { useState, useEffect, useRef, useCallback } from "react";
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal } from "lucide-react";
interface HeaderProps {
onOpenSettings?: () => void;
@@ -16,6 +17,23 @@ interface HeaderProps {
onSearchChange?: (query: string) => void;
}
function useIsMobile() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia("(max-width: 768px)").matches;
});
useEffect(() => {
if (typeof window === "undefined") return;
const mediaQuery = window.matchMedia("(max-width: 768px)");
const handleChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
return isMobile;
}
export function Header({
onOpenSettings,
onOpenGitHubImport,
@@ -31,6 +49,74 @@ export function Header({
searchQuery = "",
onSearchChange,
}: HeaderProps) {
const isMobile = useIsMobile();
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false);
const overflowButtonRef = useRef<HTMLButtonElement>(null);
const overflowMenuRef = useRef<HTMLDivElement>(null);
const mobileSearchRef = useRef<HTMLDivElement>(null);
const mobileSearchInputRef = useRef<HTMLInputElement>(null);
// Keep mobile search open if there's an active search query
const shouldShowMobileSearch = isMobileSearchOpen || searchQuery.length > 0;
// Close overflow menu on outside click
useEffect(() => {
if (!isOverflowMenuOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (
overflowMenuRef.current &&
!overflowMenuRef.current.contains(e.target as Node) &&
overflowButtonRef.current &&
!overflowButtonRef.current.contains(e.target as Node)
) {
setIsOverflowMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOverflowMenuOpen]);
// Close menus on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsOverflowMenuOpen(false);
setIsMobileSearchOpen(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
// Focus input when mobile search opens
useEffect(() => {
if (isMobileSearchOpen && mobileSearchInputRef.current) {
setTimeout(() => mobileSearchInputRef.current?.focus(), 0);
}
}, [isMobileSearchOpen]);
const handleMobileSearchToggle = useCallback(() => {
setIsMobileSearchOpen((prev) => !prev);
}, []);
const handleOverflowToggle = useCallback(() => {
setIsOverflowMenuOpen((prev) => !prev);
}, []);
const handleOverflowAction = useCallback((callback?: () => void) => {
if (callback) callback();
setIsOverflowMenuOpen(false);
}, []);
const handleMobileSearchClose = useCallback(() => {
setIsMobileSearchOpen(false);
if (onSearchChange) onSearchChange("");
}, [onSearchChange]);
return (
<header className="header">
<div className="header-left">
@@ -39,7 +125,7 @@ export function Header({
<span className="logo-sub">tasks</span>
</div>
<div className="header-actions">
{/* View Toggle */}
{/* View Toggle - always inline, even on mobile */}
{onChangeView && (
<div className="view-toggle">
<button
@@ -62,8 +148,9 @@ export function Header({
</button>
</div>
)}
{/* Search - only show in board view */}
{onSearchChange && view === "board" && (
{/* Desktop Search - only show in board view */}
{onSearchChange && view === "board" && !isMobile && (
<div className="header-search">
<Search size={14} className="header-search-icon" />
<input
@@ -84,25 +171,71 @@ export function Header({
)}
</div>
)}
{onOpenUsage && (
{/* Mobile Search Trigger - only show in board view on mobile */}
{onSearchChange && view === "board" && isMobile && (
<>
{!shouldShowMobileSearch ? (
<button
className="btn-icon mobile-search-trigger"
onClick={handleMobileSearchToggle}
title="Open search"
aria-label="Open search"
aria-expanded={false}
>
<Search size={16} />
</button>
) : (
<div
ref={mobileSearchRef}
className="header-search mobile-search-expanded"
>
<Search size={14} className="header-search-icon" />
<input
ref={mobileSearchInputRef}
type="text"
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="header-search-input"
/>
<button
className="header-search-clear"
onClick={handleMobileSearchClose}
aria-label="Close search"
>
<X size={14} />
</button>
</div>
)}
</>
)}
{/* Desktop actions */}
{!isMobile && onOpenUsage && (
<button className="btn-icon" onClick={onOpenUsage} title="View usage">
<Activity size={16} />
</button>
)}
{/* Import from GitHub */}
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
<Download size={16} />
</button>
{/* Plan button - AI-guided task creation */}
<button
className="btn-icon"
onClick={onOpenPlanning}
title="Create a task with AI planning"
data-testid="planning-btn"
>
<Lightbulb size={16} />
</button>
{/* Terminal button - always available for interactive shell access */}
{!isMobile && (
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
<Download size={16} />
</button>
)}
{!isMobile && (
<button
className="btn-icon"
onClick={onOpenPlanning}
title="Create a task with AI planning"
data-testid="planning-btn"
>
<Lightbulb size={16} />
</button>
)}
{/* Terminal button - always inline */}
<button
className="btn-icon btn-icon--terminal"
onClick={onToggleTerminal}
@@ -111,7 +244,8 @@ export function Header({
>
<Terminal size={16} />
</button>
{/* Pause button (soft pause): stops new work, lets agents finish */}
{/* Pause button (soft pause) - always inline */}
<button
className={`btn-icon${enginePaused ? " btn-icon--paused" : ""}`}
onClick={onToggleEnginePause}
@@ -120,7 +254,8 @@ export function Header({
>
{enginePaused ? <Play size={16} /> : <Pause size={16} />}
</button>
{/* Stop button (hard stop): kills all agents immediately */}
{/* Stop button (hard stop) - always inline */}
<button
className={`btn-icon${globalPaused ? " btn-icon--stopped" : ""}`}
onClick={onToggleGlobalPause}
@@ -128,9 +263,74 @@ export function Header({
>
{globalPaused ? <Play size={16} /> : <Square size={16} />}
</button>
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
<Settings size={16} />
</button>
{/* Mobile overflow menu trigger */}
{isMobile && (
<button
ref={overflowButtonRef}
className="btn-icon mobile-overflow-trigger"
onClick={handleOverflowToggle}
title="More header actions"
aria-label="More header actions"
aria-expanded={isOverflowMenuOpen}
aria-haspopup="menu"
>
<MoreHorizontal size={16} />
</button>
)}
{/* Settings - always inline on desktop */}
{!isMobile && (
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
<Settings size={16} />
</button>
)}
{/* Mobile overflow menu */}
{isMobile && isOverflowMenuOpen && (
<div
ref={overflowMenuRef}
className="mobile-overflow-menu"
role="menu"
aria-label="Additional header actions"
>
{onOpenUsage && (
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenUsage)}
role="menuitem"
>
<Activity size={16} />
<span>View usage</span>
</button>
)}
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenGitHubImport)}
role="menuitem"
>
<Download size={16} />
<span>Import from GitHub</span>
</button>
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenPlanning)}
role="menuitem"
data-testid="overflow-planning-btn"
>
<Lightbulb size={16} />
<span>Create a task with AI planning</span>
</button>
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenSettings)}
role="menuitem"
>
<Settings size={16} />
<span>Settings</span>
</button>
</div>
)}
</div>
</header>
);

View File

@@ -1,8 +1,30 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { Header } from "../Header";
// Mock matchMedia for mobile/desktop viewport tests
const mockMatchMedia = (matches: boolean) => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
};
describe("Header", () => {
beforeEach(() => {
// Default to desktop viewport
mockMatchMedia(false);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders a logo image with correct src and alt", () => {
render(<Header />);
const logo = screen.getByAltText("Fusion logo");
@@ -210,4 +232,230 @@ describe("Header", () => {
const btn = screen.getByTitle("Open Terminal");
expect((btn as HTMLButtonElement).disabled).toBe(false);
});
// ── Mobile Viewport Behavior ─────────────────────────────────────
describe("mobile viewport", () => {
beforeEach(() => {
mockMatchMedia(true); // Mobile viewport
});
it("renders mobile search trigger instead of inline search on mobile", () => {
const onSearchChange = vi.fn();
render(
<Header
view="board"
onChangeView={vi.fn()}
searchQuery=""
onSearchChange={onSearchChange}
/>
);
// Should show the trigger button, not the inline search
expect(screen.getByTitle("Open search")).toBeDefined();
// The expanded search should not be visible initially
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});
it("mobile search trigger has stable accessible name", () => {
const onSearchChange = vi.fn();
render(
<Header
view="board"
onChangeView={vi.fn()}
searchQuery=""
onSearchChange={onSearchChange}
/>
);
const trigger = screen.getByLabelText("Open search");
expect(trigger).toBeDefined();
});
it("mobile search trigger exposes aria-expanded state", () => {
const onSearchChange = vi.fn();
render(
<Header
view="board"
onChangeView={vi.fn()}
searchQuery=""
onSearchChange={onSearchChange}
/>
);
const trigger = screen.getByLabelText("Open search");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
});
it("expands mobile search when trigger is clicked", () => {
const onSearchChange = vi.fn();
render(
<Header
view="board"
onChangeView={vi.fn()}
searchQuery=""
onSearchChange={onSearchChange}
/>
);
const trigger = screen.getByTitle("Open search");
fireEvent.click(trigger);
// Search input should now be visible
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
});
it("mobile search stays expanded when searchQuery is non-empty", () => {
const onSearchChange = vi.fn();
render(
<Header
view="board"
onChangeView={vi.fn()}
searchQuery="active query"
onSearchChange={onSearchChange}
/>
);
// Even without clicking, search should be visible due to active query
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
expect(screen.getByDisplayValue("active query")).toBeDefined();
});
it("closes mobile search and clears query when close button clicked", () => {
const onSearchChange = vi.fn();
render(
<Header
view="board"
onChangeView={vi.fn()}
searchQuery="test query"
onSearchChange={onSearchChange}
/>
);
// Close the search
const closeBtn = screen.getByLabelText("Close search");
fireEvent.click(closeBtn);
expect(onSearchChange).toHaveBeenCalledWith("");
});
it("renders mobile overflow menu trigger on mobile", () => {
render(<Header onOpenSettings={vi.fn()} />);
const overflowBtn = screen.getByTitle("More header actions");
expect(overflowBtn).toBeDefined();
});
it("overflow trigger has correct ARIA attributes", () => {
render(<Header onOpenSettings={vi.fn()} />);
const overflowBtn = screen.getByLabelText("More header actions");
expect(overflowBtn.getAttribute("aria-haspopup")).toBe("menu");
expect(overflowBtn.getAttribute("aria-expanded")).toBe("false");
});
it("opens overflow menu when trigger is clicked", () => {
render(<Header onOpenSettings={vi.fn()} onOpenPlanning={vi.fn()} />);
const overflowBtn = screen.getByTitle("More header actions");
fireEvent.click(overflowBtn);
// Menu items should be visible
expect(screen.getByRole("menu")).toBeDefined();
expect(screen.getByText("Settings")).toBeDefined();
expect(screen.getByText("Create a task with AI planning")).toBeDefined();
});
it("overflow menu items dispatch correct callbacks", () => {
const onOpenSettings = vi.fn();
const onOpenPlanning = vi.fn();
const onOpenGitHubImport = vi.fn();
render(
<Header
onOpenSettings={onOpenSettings}
onOpenPlanning={onOpenPlanning}
onOpenGitHubImport={onOpenGitHubImport}
/>
);
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByText("Settings"));
expect(onOpenSettings).toHaveBeenCalled();
// Re-open menu and test planning button
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByText("Create a task with AI planning"));
expect(onOpenPlanning).toHaveBeenCalled();
});
it("closes overflow menu after selecting an action", () => {
const onOpenSettings = vi.fn();
render(<Header onOpenSettings={onOpenSettings} />);
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByText("Settings"));
// Menu should be closed
expect(screen.queryByRole("menu")).toBeNull();
});
it("closes overflow menu on outside click", () => {
render(<Header onOpenSettings={vi.fn()} />);
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByRole("menu")).toBeDefined();
// Click outside (on header)
fireEvent.mouseDown(document.body);
// Menu should be closed
expect(screen.queryByRole("menu")).toBeNull();
});
it("closes overflow menu on Escape key", () => {
render(<Header onOpenSettings={vi.fn()} />);
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByRole("menu")).toBeDefined();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("menu")).toBeNull();
});
it("hides desktop-only actions on mobile", () => {
render(
<Header
onOpenSettings={vi.fn()}
onOpenGitHubImport={vi.fn()}
onOpenPlanning={vi.fn()}
/>
);
// These buttons should not be directly visible (they're in overflow menu)
expect(screen.queryByTitle("Import from GitHub")).toBeNull();
expect(screen.queryByTitle("Create a task with AI planning")).toBeNull();
expect(screen.queryByTitle("Settings")).toBeNull();
});
it("shows view toggle inline on mobile", () => {
render(<Header view="board" onChangeView={vi.fn()} />);
// View toggle should still be visible inline
expect(screen.getByTitle("Board view")).toBeDefined();
expect(screen.getByTitle("List view")).toBeDefined();
});
it("shows terminal and pause controls inline on mobile", () => {
render(
<Header
onToggleTerminal={vi.fn()}
onToggleEnginePause={vi.fn()}
onToggleGlobalPause={vi.fn()}
/>
);
expect(screen.getByTitle("Open Terminal")).toBeDefined();
expect(screen.getByTitle("Pause scheduling")).toBeDefined();
expect(screen.getByTitle("Stop AI engine")).toBeDefined();
});
it("overflow menu includes usage button when onOpenUsage provided", () => {
render(<Header onOpenSettings={vi.fn()} onOpenUsage={vi.fn()} />);
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByText("View usage")).toBeDefined();
});
it("mobile search input dispatches onSearchChange when typing", () => {
const onSearchChange = vi.fn();
render(
<Header
view="board"
onChangeView={vi.fn()}
searchQuery=""
onSearchChange={onSearchChange}
/>
);
fireEvent.click(screen.getByTitle("Open search"));
const input = screen.getByPlaceholderText("Search tasks...");
fireEvent.change(input, { target: { value: "test" } });
expect(onSearchChange).toHaveBeenCalledWith("test");
});
});
});

View File

@@ -113,6 +113,7 @@ body {
display: flex;
align-items: center;
gap: var(--space-sm);
position: relative;
}
.btn-icon {
@@ -273,32 +274,6 @@ body {
background: var(--border);
}
/* Mobile responsive: hide search on very small screens */
@media (max-width: 640px) {
.header-search {
max-width: 160px;
min-width: 120px;
padding: 6px 8px;
}
.header-search-input {
font-size: 12px;
}
}
@media (max-width: 480px) {
.header-search {
order: 1;
width: 100%;
max-width: none;
margin-top: 8px;
}
.header-actions {
flex-wrap: wrap;
}
}
.logo {
font-size: 20px;
font-weight: 700;
@@ -2479,6 +2454,73 @@ body {
padding: 12px 12px;
}
/* Mobile header: collapsible search and overflow menu */
.mobile-search-trigger {
display: flex;
align-items: center;
justify-content: center;
}
.mobile-search-expanded {
position: absolute;
right: 140px;
left: auto;
top: 50%;
transform: translateY(-50%);
width: 200px;
max-width: 200px;
min-width: 160px;
z-index: 10;
background: var(--surface);
box-shadow: var(--shadow-md);
}
.mobile-overflow-trigger {
display: flex;
align-items: center;
justify-content: center;
}
.mobile-overflow-menu {
position: absolute;
top: calc(100% + 4px);
right: 12px;
min-width: 220px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
.mobile-overflow-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
cursor: pointer;
text-align: left;
transition: background var(--transition-fast);
}
.mobile-overflow-item:hover {
background: var(--card);
}
.mobile-overflow-item svg {
color: var(--text-muted);
flex-shrink: 0;
}
/* Modal: full-screen on mobile */
.modal-overlay {
padding-top: 0;