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

@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
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 type { ModelInfo, ProjectInfo, Agent } from "./api";
import { Header } from "./components/Header";
import { Header, useViewportMode } from "./components/Header";
import { Board } from "./components/Board";
import { ListView } from "./components/ListView";
import { ProjectOverview } from "./components/ProjectOverview";
@@ -30,6 +30,7 @@ import { NodesView } from "./components/NodesView";
import { MailboxModal } from "./components/MailboxModal";
import { ScriptsModal } from "./components/ScriptsModal";
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
import { MobileNavBar } from "./components/MobileNavBar";
import { QuickChatFAB } from "./components/QuickChatFAB";
import { useBackgroundSessions } from "./hooks/useBackgroundSessions";
import { useTasks } from "./hooks/useTasks";
@@ -79,6 +80,9 @@ function AppInner() {
return "board";
});
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
// Modal states
const [newTaskModalOpen, setNewTaskModalOpen] = useState(false);
const [isPlanningOpen, setIsPlanningOpen] = useState(false);
@@ -113,6 +117,28 @@ function AppInner() {
const [setupWizardOpen, setSetupWizardOpen] = 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
const [maxConcurrent, setMaxConcurrent] = useState(2);
const [rootDir, setRootDir] = useState<string>(".");
@@ -722,9 +748,10 @@ function AppInner() {
onSelectProject={handleSelectProject}
onViewAllProjects={handleViewAllProjects}
projectId={currentProject?.id}
mobileNavEnabled={isMobile}
/>
<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()}
</div>
@@ -751,6 +778,30 @@ function AppInner() {
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 && (
<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;
projectId?: string;
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 {
if (typeof window === "undefined") return "desktop";
@@ -71,7 +73,7 @@ function getViewportMode(): ViewportMode {
return "desktop";
}
function useViewportMode(): ViewportMode {
export function useViewportMode(): ViewportMode {
const [mode, setMode] = useState<ViewportMode>(getViewportMode);
useEffect(() => {
@@ -135,11 +137,13 @@ export function Header({
onViewAllProjects,
projectId,
isElectron = false,
mobileNavEnabled,
}: HeaderProps) {
const mode = useViewportMode();
const isMobile = mode === "mobile";
const isTablet = mode === "tablet";
const isCompact = isMobile || isTablet;
const hideFullNav = isMobile && mobileNavEnabled;
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false);
const [isTerminalSubmenuOpen, setIsTerminalSubmenuOpen] = useState(false);
@@ -381,7 +385,7 @@ export function Header({
)}
{/* View Toggle - always inline, even on mobile */}
{onChangeView && (
{!hideFullNav && onChangeView && (
<div className="view-toggle">
<button
className={`view-toggle-btn${view === "board" ? " active" : ""}`}
@@ -592,7 +596,7 @@ export function Header({
</button>
{/* Compact overflow menu trigger (mobile + tablet) */}
{isCompact && (
{isCompact && !hideFullNav && (
<button
ref={overflowButtonRef}
className="btn-icon compact-overflow-trigger"
@@ -607,7 +611,7 @@ export function Header({
)}
{/* Compact overflow menu (mobile + tablet) */}
{isCompact && isOverflowMenuOpen && (
{isCompact && !hideFullNav && isOverflowMenuOpen && (
<div
ref={overflowMenuRef}
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();
});
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", () => {
render(
<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));
}
}
/* === 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;
}