feat(FN-1108): add mobile bottom navigation to dashboard

- Export viewport utilities and add a new MobileNavBar component for core mobile navigation actions
- Integrate the bottom nav into App layout and simplify Header rendering when mobile navigation is active
- Add comprehensive mobile bottom-nav styling and spacing updates in the shared dashboard stylesheet
- Add component and CSS regression tests for mobile nav behavior, including escape-key interaction coverage
- Document the mobile bottom navigation UX and behavior in the dashboard README
This commit is contained in:
gsxdsm
2026-04-08 04:20:31 -07:00
parent f35a0d5991
commit 3bb58cd9b8
8 changed files with 931 additions and 7 deletions

View File

@@ -66,6 +66,55 @@ The dashboard header adapts across three responsive tiers to remain usable witho
- **Desktop (>1024px)**: Full header with all controls and the project selector inline. No overflow menu. - **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. - **Keyboard Accessible**: All controls across tiers expose proper ARIA attributes (aria-expanded, aria-haspopup, aria-label) and support keyboard navigation.
### Mobile Bottom Navigation
The dashboard now includes a dedicated bottom tab navigation pattern for mobile viewports (`≤768px`) via `MobileNavBar` (`app/components/MobileNavBar.tsx`). This pattern is designed for narrow screens and Capacitor-wrapped app usage where bottom-tab navigation is the primary interaction model.
**Primary tabs:**
- **Board/List** — switches task views (label reflects the current non-agent task view)
- **Agents** — switches to the Agents view
- **Activity** — opens the Activity Log modal
- **More** — opens a bottom-sheet drawer for secondary navigation actions
**More sheet items include:** Mailbox, Missions, Git Manager, Terminal, Files, Planning, Workflow Steps, Schedules, GitHub Import, Usage, and Settings.
**Behavior details:**
- The tab bar is mobile-only and hidden on tablet/desktop.
- The tab bar automatically hides when full-screen modals are open (`modalOpen`).
- Badge counts on the Activity tab combine unread mailbox count and active planning sessions (capped at `99+`).
- Touch targets in both tabs and sheet items meet a minimum 44px height.
- Safe-area support uses `env(safe-area-inset-bottom, 0px)` for devices with home-indicator insets.
**Header simplification with mobile nav:**
When `Header` receives `mobileNavEnabled={true}` on mobile, top-nav controls are reduced to:
- project/brand area,
- mobile search control,
- engine pause/stop controls.
The inline view toggle and compact overflow menu are intentionally hidden in this mode, since primary navigation is handled by the bottom tab bar.
**Viewport hook export:**
`Header.tsx` now exports both:
- `ViewportMode`
- `useViewportMode()`
so the app layout and mobile nav can share the same breakpoint logic.
**Key CSS classes:**
- `.mobile-nav-bar`
- `.mobile-nav-tab`
- `.mobile-more-sheet`
- `.mobile-more-item`
- `.project-content--with-mobile-nav`
The layout combines footer + mobile-nav spacing using compound selectors:
- `.project-content--with-footer.project-content--with-mobile-nav`
- `.project-content--with-mobile-nav:not(.project-content--with-footer)`
so content never scrolls behind fixed bottom surfaces.
**Testing notes:**
For component tests, mock `window.matchMedia` to return mobile matches for `(max-width: 768px)` and desktop/tablet matches as needed. See `MobileNavBar.test.tsx` for the reference mocking pattern.
### Mobile Task Entry ### Mobile Task Entry
Task entry inputs (the quick entry box in the Triage column and the New Task modal's description field) are sized to prevent browser zoom-on-focus on iOS Safari. On mobile viewports (≤768px), these inputs use a minimum 16px font size, which keeps the viewport stable when users focus the fields. Task entry inputs (the quick entry box in the Triage column and the New Task modal's description field) are sized to prevent browser zoom-on-focus on iOS Safari. On mobile viewports (≤768px), these inputs use a minimum 16px font size, which keeps the viewport stable when users focus the fields.

View File

@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core"; import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
import { fetchConfig, fetchSettings, fetchAuthStatus, fetchGlobalSettings, updateSettings, updateGlobalSettings, fetchModels, fetchTaskDetail, updateProject, unregisterProject, fetchUnreadCount, fetchAgents } from "./api"; import { fetchConfig, fetchSettings, fetchAuthStatus, fetchGlobalSettings, updateSettings, updateGlobalSettings, fetchModels, fetchTaskDetail, updateProject, unregisterProject, fetchUnreadCount, fetchAgents } from "./api";
import type { ModelInfo, ProjectInfo, Agent } from "./api"; import type { ModelInfo, ProjectInfo, Agent } from "./api";
import { Header } from "./components/Header"; import { Header, useViewportMode } from "./components/Header";
import { Board } from "./components/Board"; import { Board } from "./components/Board";
import { ListView } from "./components/ListView"; import { ListView } from "./components/ListView";
import { ProjectOverview } from "./components/ProjectOverview"; import { ProjectOverview } from "./components/ProjectOverview";
@@ -30,6 +30,7 @@ import { NodesView } from "./components/NodesView";
import { MailboxModal } from "./components/MailboxModal"; import { MailboxModal } from "./components/MailboxModal";
import { ScriptsModal } from "./components/ScriptsModal"; import { ScriptsModal } from "./components/ScriptsModal";
import { ExecutorStatusBar } from "./components/ExecutorStatusBar"; import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
import { MobileNavBar } from "./components/MobileNavBar";
import { QuickChatFAB } from "./components/QuickChatFAB"; import { QuickChatFAB } from "./components/QuickChatFAB";
import { useBackgroundSessions } from "./hooks/useBackgroundSessions"; import { useBackgroundSessions } from "./hooks/useBackgroundSessions";
import { useTasks } from "./hooks/useTasks"; import { useTasks } from "./hooks/useTasks";
@@ -79,6 +80,9 @@ function AppInner() {
return "board"; return "board";
}); });
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
// Modal states // Modal states
const [newTaskModalOpen, setNewTaskModalOpen] = useState(false); const [newTaskModalOpen, setNewTaskModalOpen] = useState(false);
const [isPlanningOpen, setIsPlanningOpen] = useState(false); const [isPlanningOpen, setIsPlanningOpen] = useState(false);
@@ -113,6 +117,28 @@ function AppInner() {
const [setupWizardOpen, setSetupWizardOpen] = useState(false); const [setupWizardOpen, setSetupWizardOpen] = useState(false);
const [modelOnboardingOpen, setModelOnboardingOpen] = useState(false); const [modelOnboardingOpen, setModelOnboardingOpen] = useState(false);
const anyModalOpen = !!(
detailTask ||
settingsOpen ||
newTaskModalOpen ||
isPlanningOpen ||
isSubtaskOpen ||
terminalOpen ||
filesOpen ||
activityLogOpen ||
mailboxOpen ||
gitManagerOpen ||
workflowStepsOpen ||
missionsOpen ||
scriptsOpen ||
agentsOpen ||
usageOpen ||
schedulesOpen ||
githubImportOpen ||
setupWizardOpen ||
modelOnboardingOpen
);
// Settings state // Settings state
const [maxConcurrent, setMaxConcurrent] = useState(2); const [maxConcurrent, setMaxConcurrent] = useState(2);
const [rootDir, setRootDir] = useState<string>("."); const [rootDir, setRootDir] = useState<string>(".");
@@ -722,9 +748,10 @@ function AppInner() {
onSelectProject={handleSelectProject} onSelectProject={handleSelectProject}
onViewAllProjects={handleViewAllProjects} onViewAllProjects={handleViewAllProjects}
projectId={currentProject?.id} projectId={currentProject?.id}
mobileNavEnabled={isMobile}
/> />
<div <div
className={`project-content${viewMode === "project" && currentProject ? " project-content--with-footer" : ""}`} className={`project-content${viewMode === "project" && currentProject ? " project-content--with-footer" : ""}${isMobile ? " project-content--with-mobile-nav" : ""}`}
> >
{renderMainContent()} {renderMainContent()}
</div> </div>
@@ -751,6 +778,30 @@ function AppInner() {
onDismissBackgroundSession={bgDismiss} onDismissBackgroundSession={bgDismiss}
/> />
)} )}
<MobileNavBar
view={taskView}
onChangeView={viewMode === "project" && currentProject ? handleChangeTaskView : () => {}}
footerVisible={viewMode === "project" && !!currentProject}
modalOpen={anyModalOpen}
onOpenSettings={() => setSettingsOpen(true)}
onOpenActivityLog={handleOpenActivityLog}
onOpenMailbox={handleOpenMailbox}
mailboxUnreadCount={mailboxUnreadCount}
onOpenGitManager={handleOpenGitManager}
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
onOpenMissions={viewMode === "project" && currentProject ? () => setMissionsOpen(true) : undefined}
onOpenSchedules={handleOpenSchedules}
onOpenScripts={handleOpenScripts}
onToggleTerminal={handleToggleTerminal}
onOpenFiles={handleOpenFiles}
onOpenGitHubImport={() => setGitHubImportOpen(true)}
onOpenPlanning={handlePlanningOpen}
onResumePlanning={handleResumePlanning}
activePlanningSessionCount={bgPlanningSessions.length}
onOpenUsage={handleOpenUsage}
onRunScript={handleRunScript}
projectId={currentProject?.id}
/>
{viewMode === "project" && currentProject && ( {viewMode === "project" && currentProject && (
<QuickChatFAB projectId={currentProject.id} addToast={addToast} /> <QuickChatFAB projectId={currentProject.id} addToast={addToast} />
)} )}

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
function extractRuleBlock(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([\\s\\S]*?)\\}`));
return match?.[1] ?? "";
}
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;
let braceCount = 1;
let endIdx = startIdx;
while (braceCount > 0 && endIdx < content.length) {
if (content[endIdx] === "{") braceCount += 1;
if (content[endIdx] === "}") braceCount -= 1;
endIdx += 1;
}
if (braceCount === 0) {
blocks.push(content.slice(startIdx, endIdx - 1));
}
}
return blocks.join("\n");
}
describe("mobile-nav-bar.css", () => {
const cssPath = resolve(__dirname, "../styles.css");
const cssContent = readFileSync(cssPath, "utf-8");
const mobileMediaBlock = extractMobileMediaBlocks(cssContent);
it("tab bar has fixed position", () => {
const block = extractRuleBlock(cssContent, ".mobile-nav-bar");
expect(block).toContain("position: fixed");
expect(block).toContain("bottom: 0");
});
it("tab bar display toggles in mobile media query", () => {
const block = extractRuleBlock(cssContent, ".mobile-nav-bar");
expect(block).toContain("display: none");
expect(cssContent).toContain("@media (max-width: 768px)");
expect(cssContent).toMatch(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.mobile-nav-bar\s*\{[\s\S]*?display:\s*flex[;\s]/);
});
it("tab touch targets are at least 44px", () => {
const block = extractRuleBlock(cssContent, ".mobile-nav-tab");
expect(block).toContain("min-height: 44px");
});
it("footer-aware positioning places nav above footer", () => {
expect(cssContent).toContain(".mobile-nav-bar--with-footer");
expect(cssContent).toContain("bottom: 32px");
});
it("defines bottom sheet animation", () => {
expect(cssContent).toContain("@keyframes mobile-more-sheet-in");
});
it("uses safe-area inset for bottom spacing", () => {
expect(cssContent).toContain("env(safe-area-inset-bottom");
});
it("tab bar includes z-index", () => {
const block = extractRuleBlock(cssContent, ".mobile-nav-bar");
expect(block).toContain("z-index: 45");
});
it("sheet items maintain 44px touch targets", () => {
const block = extractRuleBlock(cssContent, ".mobile-more-item");
expect(block).toContain("min-height: 44px");
});
it("defines content padding rule for mobile nav", () => {
expect(mobileMediaBlock).toContain(".project-content--with-mobile-nav");
expect(cssContent).toContain(".project-content--with-footer.project-content--with-mobile-nav");
});
});

View File

@@ -60,9 +60,11 @@ export interface HeaderProps {
onViewAllProjects?: () => void; onViewAllProjects?: () => void;
projectId?: string; projectId?: string;
isElectron?: boolean; isElectron?: boolean;
/** When true, the mobile bottom nav bar handles primary navigation and header nav controls are hidden. */
mobileNavEnabled?: boolean;
} }
type ViewportMode = "mobile" | "tablet" | "desktop"; export type ViewportMode = "mobile" | "tablet" | "desktop";
function getViewportMode(): ViewportMode { function getViewportMode(): ViewportMode {
if (typeof window === "undefined") return "desktop"; if (typeof window === "undefined") return "desktop";
@@ -71,7 +73,7 @@ function getViewportMode(): ViewportMode {
return "desktop"; return "desktop";
} }
function useViewportMode(): ViewportMode { export function useViewportMode(): ViewportMode {
const [mode, setMode] = useState<ViewportMode>(getViewportMode); const [mode, setMode] = useState<ViewportMode>(getViewportMode);
useEffect(() => { useEffect(() => {
@@ -135,11 +137,13 @@ export function Header({
onViewAllProjects, onViewAllProjects,
projectId, projectId,
isElectron = false, isElectron = false,
mobileNavEnabled,
}: HeaderProps) { }: HeaderProps) {
const mode = useViewportMode(); const mode = useViewportMode();
const isMobile = mode === "mobile"; const isMobile = mode === "mobile";
const isTablet = mode === "tablet"; const isTablet = mode === "tablet";
const isCompact = isMobile || isTablet; const isCompact = isMobile || isTablet;
const hideFullNav = isMobile && mobileNavEnabled;
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false); const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false);
const [isTerminalSubmenuOpen, setIsTerminalSubmenuOpen] = useState(false); const [isTerminalSubmenuOpen, setIsTerminalSubmenuOpen] = useState(false);
@@ -381,7 +385,7 @@ export function Header({
)} )}
{/* View Toggle - always inline, even on mobile */} {/* View Toggle - always inline, even on mobile */}
{onChangeView && ( {!hideFullNav && onChangeView && (
<div className="view-toggle"> <div className="view-toggle">
<button <button
className={`view-toggle-btn${view === "board" ? " active" : ""}`} className={`view-toggle-btn${view === "board" ? " active" : ""}`}
@@ -592,7 +596,7 @@ export function Header({
</button> </button>
{/* Compact overflow menu trigger (mobile + tablet) */} {/* Compact overflow menu trigger (mobile + tablet) */}
{isCompact && ( {isCompact && !hideFullNav && (
<button <button
ref={overflowButtonRef} ref={overflowButtonRef}
className="btn-icon compact-overflow-trigger" className="btn-icon compact-overflow-trigger"
@@ -607,7 +611,7 @@ export function Header({
)} )}
{/* Compact overflow menu (mobile + tablet) */} {/* Compact overflow menu (mobile + tablet) */}
{isCompact && isOverflowMenuOpen && ( {isCompact && !hideFullNav && isOverflowMenuOpen && (
<div <div
ref={overflowMenuRef} ref={overflowMenuRef}
className="mobile-overflow-menu" className="mobile-overflow-menu"

View File

@@ -0,0 +1,316 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Activity,
Bot,
Clock,
Folder,
GitBranch,
LayoutGrid,
Lightbulb,
Mail,
MoreHorizontal,
Settings,
Target,
Terminal,
Workflow,
} from "lucide-react";
import { useViewportMode } from "./Header";
export interface MobileNavBarProps {
/** Current task view mode */
view: "board" | "list" | "agents";
/** Change task view handler */
onChangeView: (view: "board" | "list" | "agents") => void;
/** Whether the ExecutorStatusBar footer is visible */
footerVisible: boolean;
/** Whether any full-screen modal is currently open (hides the tab bar) */
modalOpen?: boolean;
// Navigation handlers
onOpenSettings?: () => void;
onOpenActivityLog?: () => void;
onOpenMailbox?: () => void;
mailboxUnreadCount?: number;
onOpenGitManager?: () => void;
onOpenWorkflowSteps?: () => void;
onOpenMissions?: () => void;
onOpenSchedules?: () => void;
onOpenScripts?: () => void;
onToggleTerminal?: () => void;
onOpenFiles?: () => void;
onOpenGitHubImport?: () => void;
onOpenPlanning?: () => void;
onResumePlanning?: () => void;
activePlanningSessionCount?: number;
onOpenUsage?: () => void;
onRunScript?: (name: string, command: string) => void;
projectId?: string;
}
function GitHubLogo({ size = 20 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
);
}
function formatCount(count: number): string {
return count > 99 ? "99+" : String(count);
}
export function MobileNavBar({
view,
onChangeView,
footerVisible,
modalOpen = false,
onOpenSettings,
onOpenActivityLog,
onOpenMailbox,
mailboxUnreadCount = 0,
onOpenGitManager,
onOpenWorkflowSteps,
onOpenMissions,
onOpenSchedules,
onToggleTerminal,
onOpenFiles,
onOpenGitHubImport,
onOpenPlanning,
onResumePlanning,
activePlanningSessionCount = 0,
onOpenUsage,
}: MobileNavBarProps) {
const mode = useViewportMode();
const [isMoreOpen, setIsMoreOpen] = useState(false);
const combinedCount = useMemo(
() => activePlanningSessionCount + mailboxUnreadCount,
[activePlanningSessionCount, mailboxUnreadCount],
);
const closeMore = useCallback(() => setIsMoreOpen(false), []);
const handleMoreAction = useCallback(
(callback?: () => void) => {
closeMore();
callback?.();
},
[closeMore],
);
useEffect(() => {
if (!isMoreOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsMoreOpen(false);
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isMoreOpen]);
if (mode !== "mobile" || modalOpen) {
return null;
}
const boardTabLabel = view === "list" ? "List" : "Board";
const isBoardOrList = view === "board" || view === "list";
const planningHandler = activePlanningSessionCount > 0 && onResumePlanning ? onResumePlanning : onOpenPlanning;
return (
<>
<nav
className={`mobile-nav-bar${footerVisible ? " mobile-nav-bar--with-footer" : ""}`}
role="tablist"
aria-label="Primary navigation"
>
<button
type="button"
className={`mobile-nav-tab${isBoardOrList ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-board"
role="tab"
aria-selected={isBoardOrList}
onClick={() => onChangeView("board")}
>
<LayoutGrid />
<span className="mobile-nav-tab-label">{boardTabLabel}</span>
</button>
<button
type="button"
className={`mobile-nav-tab${view === "agents" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-agents"
role="tab"
aria-selected={view === "agents"}
onClick={() => onChangeView("agents")}
>
<Bot />
<span className="mobile-nav-tab-label">Agents</span>
</button>
<button
type="button"
className="mobile-nav-tab"
data-testid="mobile-nav-tab-activity"
role="tab"
aria-selected={false}
onClick={() => onOpenActivityLog?.()}
>
<Activity />
<span className="mobile-nav-tab-label">Activity</span>
{combinedCount > 0 && <span className="mobile-nav-badge">{formatCount(combinedCount)}</span>}
</button>
<button
type="button"
className="mobile-nav-tab"
data-testid="mobile-nav-tab-more"
role="tab"
aria-selected={false}
onClick={() => setIsMoreOpen((prev) => !prev)}
>
<MoreHorizontal />
<span className="mobile-nav-tab-label">More</span>
</button>
</nav>
{isMoreOpen && (
<>
<div
className="mobile-more-sheet-backdrop"
onClick={closeMore}
/>
<div className="mobile-more-sheet">
<div className="mobile-more-sheet-handle" />
<div className="mobile-more-sheet-title">Navigate</div>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-mailbox"
onClick={() => handleMoreAction(onOpenMailbox)}
>
<Mail />
<span>Mailbox</span>
{mailboxUnreadCount > 0 && (
<span className="mobile-more-item-badge">{formatCount(mailboxUnreadCount)}</span>
)}
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-missions"
onClick={() => handleMoreAction(onOpenMissions)}
>
<Target />
<span>Missions</span>
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-git"
onClick={() => handleMoreAction(onOpenGitManager)}
>
<GitBranch />
<span>Git Manager</span>
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-terminal"
onClick={() => handleMoreAction(onToggleTerminal)}
>
<Terminal />
<span>Terminal</span>
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-files"
onClick={() => handleMoreAction(onOpenFiles)}
>
<Folder />
<span>Files</span>
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-planning"
onClick={() => handleMoreAction(planningHandler)}
>
<Lightbulb />
<span>Planning</span>
{activePlanningSessionCount > 0 && (
<span className="mobile-more-item-badge">{formatCount(activePlanningSessionCount)}</span>
)}
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-workflow"
onClick={() => handleMoreAction(onOpenWorkflowSteps)}
>
<Workflow />
<span>Workflow Steps</span>
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-schedules"
onClick={() => handleMoreAction(onOpenSchedules)}
>
<Clock />
<span>Schedules</span>
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-github"
onClick={() => handleMoreAction(onOpenGitHubImport)}
>
<GitHubLogo />
<span>Import from GitHub</span>
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-usage"
onClick={() => handleMoreAction(onOpenUsage)}
>
<Activity />
<span>Usage</span>
</button>
<div className="mobile-more-separator" />
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-settings"
onClick={() => handleMoreAction(onOpenSettings)}
>
<Settings />
<span>Settings</span>
</button>
</div>
</>
)}
</>
);
}

View File

@@ -552,6 +552,36 @@ describe("Header", () => {
expect(screen.getByTitle("List view")).toBeDefined(); expect(screen.getByTitle("List view")).toBeDefined();
}); });
it("hides view toggle when mobileNavEnabled is true", () => {
render(<Header view="board" onChangeView={vi.fn()} mobileNavEnabled={true} />);
expect(screen.queryByTitle("Board view")).toBeNull();
expect(screen.queryByTitle("List view")).toBeNull();
});
it("hides overflow trigger when mobileNavEnabled is true", () => {
render(<Header onOpenSettings={vi.fn()} mobileNavEnabled={true} />);
expect(screen.queryByTitle("More header actions")).toBeNull();
});
it("keeps engine controls visible when mobileNavEnabled is true", () => {
render(
<Header
mobileNavEnabled={true}
onToggleEnginePause={vi.fn()}
onToggleGlobalPause={vi.fn()}
/>
);
expect(screen.getByTitle("Pause scheduling")).toBeDefined();
expect(screen.getByTitle("Stop AI engine")).toBeDefined();
});
it("keeps overflow trigger visible on tablet when mobileNavEnabled is true", () => {
mockMatchMedia("tablet");
render(<Header onOpenSettings={vi.fn()} mobileNavEnabled={true} />);
expect(screen.getByTitle("More header actions")).toBeDefined();
});
it("shows terminal group in overflow menu and pause controls inline on mobile", () => { it("shows terminal group in overflow menu and pause controls inline on mobile", () => {
render( render(
<Header <Header

View File

@@ -0,0 +1,187 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MobileNavBar } from "../MobileNavBar";
function mockViewport(mode: "mobile" | "desktop") {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => {
const isMobileQuery = query === "(max-width: 768px)";
const isTabletQuery = query === "(min-width: 769px) and (max-width: 1024px)";
return {
matches: mode === "mobile" ? isMobileQuery : false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
};
}),
});
}
const createDefaultProps = () => ({
view: "board" as const,
onChangeView: vi.fn(),
footerVisible: false,
modalOpen: false,
onOpenSettings: vi.fn(),
onOpenActivityLog: vi.fn(),
onOpenMailbox: vi.fn(),
mailboxUnreadCount: 0,
onOpenGitManager: vi.fn(),
onOpenWorkflowSteps: vi.fn(),
onOpenMissions: vi.fn(),
onOpenSchedules: vi.fn(),
onOpenScripts: vi.fn(),
onToggleTerminal: vi.fn(),
onOpenFiles: vi.fn(),
onOpenGitHubImport: vi.fn(),
onOpenPlanning: vi.fn(),
onResumePlanning: vi.fn(),
activePlanningSessionCount: 0,
onOpenUsage: vi.fn(),
onRunScript: vi.fn(),
projectId: "proj_1",
});
describe("MobileNavBar", () => {
beforeEach(() => {
mockViewport("mobile");
});
it("renders four tab buttons", () => {
render(<MobileNavBar {...createDefaultProps()} />);
expect(screen.getByTestId("mobile-nav-tab-board")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-agents")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-activity")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-more")).toBeDefined();
});
it("active tab is highlighted", () => {
render(<MobileNavBar {...createDefaultProps()} view="agents" />);
expect(screen.getByTestId("mobile-nav-tab-agents").className).toContain("mobile-nav-tab--active");
expect(screen.getByTestId("mobile-nav-tab-board").className).not.toContain("mobile-nav-tab--active");
});
it("board/list label changes based on current view", () => {
const props = createDefaultProps();
const { rerender } = render(<MobileNavBar {...props} view="board" />);
expect(screen.getByText("Board")).toBeDefined();
rerender(<MobileNavBar {...props} view="list" />);
expect(screen.getByText("List")).toBeDefined();
});
it("shows activity badge with combined planning + mailbox count", () => {
const { container } = render(
<MobileNavBar
{...createDefaultProps()}
activePlanningSessionCount={2}
mailboxUnreadCount={3}
/>,
);
const activityTab = screen.getByTestId("mobile-nav-tab-activity");
const badge = activityTab.querySelector(".mobile-nav-badge");
expect(badge).not.toBeNull();
expect(badge?.textContent).toBe("5");
expect(container.querySelectorAll(".mobile-nav-badge")).toHaveLength(1);
});
it("hides badge when combined count is zero", () => {
const { container } = render(<MobileNavBar {...createDefaultProps()} mailboxUnreadCount={0} activePlanningSessionCount={0} />);
expect(container.querySelector(".mobile-nav-badge")).toBeNull();
});
it("caps badge at 99+", () => {
render(<MobileNavBar {...createDefaultProps()} mailboxUnreadCount={75} activePlanningSessionCount={75} />);
expect(screen.getByText("99+")).toBeDefined();
});
it("opens and toggles the more sheet", () => {
const props = createDefaultProps();
const { container } = render(<MobileNavBar {...props} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(container.querySelector(".mobile-more-sheet")).not.toBeNull();
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
});
it("sheet contains expected navigation items", () => {
render(<MobileNavBar {...createDefaultProps()} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.getByTestId("mobile-more-item-mailbox")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-missions")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-git")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-terminal")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-files")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-planning")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-workflow")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-schedules")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-github")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-usage")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-settings")).toBeDefined();
});
it("closes sheet and calls handler when item is clicked", () => {
const props = createDefaultProps();
const { container } = render(<MobileNavBar {...props} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
fireEvent.click(screen.getByTestId("mobile-more-item-settings"));
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
expect(props.onOpenSettings).toHaveBeenCalledOnce();
});
it("closes sheet on backdrop click", () => {
const { container } = render(<MobileNavBar {...createDefaultProps()} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
const backdrop = container.querySelector(".mobile-more-sheet-backdrop");
expect(backdrop).not.toBeNull();
fireEvent.click(backdrop!);
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
});
it("closes sheet on Escape", async () => {
const { container } = render(<MobileNavBar {...createDefaultProps()} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
});
});
it("returns null when modalOpen is true", () => {
const { container } = render(<MobileNavBar {...createDefaultProps()} modalOpen={true} />);
expect(container.querySelector(".mobile-nav-bar")).toBeNull();
});
it("tab click calls onChangeView", () => {
const props = createDefaultProps();
render(<MobileNavBar {...props} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-agents"));
expect(props.onChangeView).toHaveBeenCalledWith("agents");
});
it("applies footer-visible class when footer is shown", () => {
const { container } = render(<MobileNavBar {...createDefaultProps()} footerVisible={true} />);
expect(container.querySelector(".mobile-nav-bar--with-footer")).not.toBeNull();
});
it("returns null on desktop viewport", () => {
mockViewport("desktop");
const { container } = render(<MobileNavBar {...createDefaultProps()} />);
expect(container.querySelector(".mobile-nav-bar")).toBeNull();
});
});

View File

@@ -23236,3 +23236,205 @@ html .column.drag-over * {
height: min(420px, calc(100dvh - 160px)); height: min(420px, calc(100dvh - 160px));
} }
} }
/* === Mobile Bottom Tab Bar (FN-1108) ===
Fixed bottom navigation for mobile viewports (≤768px).
Sits above the ExecutorStatusBar footer when visible.
Uses safe-area-inset-bottom for notched devices. */
.mobile-nav-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 45;
display: none;
align-items: stretch;
background: var(--surface);
border-top: 1px solid var(--border);
min-height: 56px;
padding-bottom: env(safe-area-inset-bottom, 0px);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15);
}
@media (max-width: 768px) {
.mobile-nav-bar {
display: flex;
}
.mobile-nav-bar--with-footer {
bottom: 32px;
}
/* Content padding: mobile nav only (no footer) */
.project-content--with-mobile-nav:not(.project-content--with-footer) {
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
}
/* Content padding: both mobile nav AND footer */
.project-content--with-footer.project-content--with-mobile-nav {
padding-bottom: calc(32px + 56px + env(safe-area-inset-bottom, 0px));
}
}
/* Individual tab button */
.mobile-nav-tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-height: 44px;
padding: 6px 0;
background: none;
border: none;
color: var(--text-muted);
font-size: 10px;
line-height: 1.2;
cursor: pointer;
transition: color 0.15s ease;
position: relative;
-webkit-tap-highlight-color: transparent;
}
.mobile-nav-tab:active {
color: var(--text-primary);
}
.mobile-nav-tab--active {
color: var(--accent);
}
.mobile-nav-tab svg {
width: 22px;
height: 22px;
}
.mobile-nav-tab-label {
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
}
/* Badge on tab */
.mobile-nav-badge {
position: absolute;
top: 2px;
right: 50%;
transform: translateX(calc(50% + 10px));
min-width: 16px;
height: 16px;
border-radius: 8px;
background: var(--accent);
color: var(--surface);
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
line-height: 1;
}
/* === Mobile More Sheet (Bottom Drawer) === */
.mobile-more-sheet-backdrop {
position: fixed;
inset: 0;
z-index: 90;
background: rgba(0, 0, 0, 0.5);
animation: mobile-more-backdrop-in 0.2s ease;
}
.mobile-more-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 91;
background: var(--surface);
border-top-left-radius: var(--radius-lg, 12px);
border-top-right-radius: var(--radius-lg, 12px);
padding: 16px 0;
padding-bottom: max(16px, env(safe-area-inset-bottom, 0px));
max-height: 70vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
animation: mobile-more-sheet-in 0.25s ease;
}
@keyframes mobile-more-backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes mobile-more-sheet-in {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.mobile-more-sheet-handle {
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--border);
margin: 0 auto 12px;
}
.mobile-more-sheet-title {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
padding: 0 16px 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.mobile-more-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 16px;
min-height: 44px;
background: none;
border: none;
color: var(--text-primary);
font-size: 15px;
cursor: pointer;
transition: background 0.15s ease;
-webkit-tap-highlight-color: transparent;
position: relative;
}
.mobile-more-item:active {
background: var(--surface-hover, rgba(0, 0, 0, 0.05));
}
.mobile-more-item svg {
width: 20px;
height: 20px;
color: var(--text-muted);
}
.mobile-more-item-badge {
margin-left: auto;
min-width: 20px;
height: 20px;
border-radius: 10px;
background: var(--accent);
color: var(--surface);
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px;
}
.mobile-more-separator {
height: 1px;
background: var(--border);
margin: 8px 16px;
}