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.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
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;
|
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"
|
||||||
|
|||||||
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();
|
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
|
||||||
|
|||||||
@@ -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));
|
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