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:
@@ -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.
|
||||
- **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
|
||||
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.
|
||||
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
85
packages/dashboard/app/__tests__/mobile-nav-bar-css.test.ts
Normal file
85
packages/dashboard/app/__tests__/mobile-nav-bar-css.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
316
packages/dashboard/app/components/MobileNavBar.tsx
Normal file
316
packages/dashboard/app/components/MobileNavBar.tsx
Normal 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user