import "./MobileNavBar.css"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Activity, Bot, Brain, CheckSquare, ChevronRight, Clock, FileCode, FileText, Folder, GitBranch, Grid3X3, LayoutGrid, Lightbulb, Loader2, Mail, MessageSquare, MoreHorizontal, Play, Settings, Monitor, Network, Search, Sparkles, Target, Terminal, Workflow, Map, Zap, } from "lucide-react"; import { fetchScripts } from "../api"; import type { PluginDashboardViewEntry } from "../api"; import { useViewportMode } from "./Header"; import type { TaskView } from "../hooks/useViewState"; import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry"; import { getPluginNavIcon } from "./pluginNavIcon"; export interface MobileNavBarProps { /** Current task view mode */ view: TaskView; /** Change task view handler */ onChangeView: (view: TaskView) => void; /** Whether the ExecutorStatusBar footer is visible */ footerVisible: boolean; /** Whether any full-screen modal is currently open (hides the tab bar) */ modalOpen?: boolean; /** Whether the on-screen mobile keyboard is open (hides the tab bar) */ keyboardOpen?: boolean; // Navigation handlers onOpenSettings?: () => void; onOpenActivityLog?: () => void; onOpenSystemStats?: () => void; onOpenMailbox?: () => void; mailboxUnreadCount?: number; onOpenGitManager?: () => void; onOpenWorkflowSteps?: () => void; onOpenSchedules?: () => void; onOpenScripts?: () => void; onToggleTerminal?: () => void; onOpenFiles?: () => void; onOpenTodos?: () => void; todosOpen?: boolean; onOpenGitHubImport?: () => void; onOpenPlanning?: () => void; onResumePlanning?: () => void; activePlanningSessionCount?: number; onOpenUsage?: () => void; onRunScript?: (name: string, command: string) => void; projectId?: string; onViewAllProjects?: () => void; /** Whether to show the skills tab */ showSkillsTab?: boolean; /** Experimental feature flags controlling visibility of nav items. */ experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; todoView?: boolean; researchView?: boolean; nodesView?: boolean; }; onOpenNodes?: () => void; pluginDashboardViews?: PluginDashboardViewEntry[]; } function GitHubLogo({ size = 20 }: { size?: number }) { return ( ); } function formatCount(count: number): string { return count > 99 ? "99+" : String(count); } export function MobileNavBar({ view, onChangeView, footerVisible, modalOpen = false, keyboardOpen = false, onOpenSettings, onOpenActivityLog, onOpenSystemStats, onOpenMailbox, mailboxUnreadCount = 0, onOpenGitManager, onOpenWorkflowSteps, onOpenSchedules, onOpenScripts, onToggleTerminal, onOpenFiles, onOpenTodos, todosOpen = false, onOpenGitHubImport, onOpenPlanning, onResumePlanning, activePlanningSessionCount = 0, onOpenUsage, onRunScript, projectId, onViewAllProjects, showSkillsTab, experimentalFeatures, onOpenNodes, pluginDashboardViews = [], }: MobileNavBarProps) { const mode = useViewportMode(); const [isMoreOpen, setIsMoreOpen] = useState(false); const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false); const [scripts, setScripts] = useState>({}); const [scriptsLoading, setScriptsLoading] = useState(false); const scriptEntries = useMemo( () => Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b)), [scripts], ); // Fetch scripts when the submenu opens useEffect(() => { if (!isScriptsSubmenuOpen) return; let cancelled = false; setScriptsLoading(true); fetchScripts(projectId) .then((data) => { if (!cancelled) setScripts(data); }) .catch(() => { if (!cancelled) setScripts({}); }) .finally(() => { if (!cancelled) setScriptsLoading(false); }); return () => { cancelled = true; }; }, [isScriptsSubmenuOpen, projectId]); 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 || keyboardOpen) { return null; } const planningHandler = activePlanningSessionCount > 0 && onResumePlanning ? onResumePlanning : onOpenPlanning; const hasRoadmapsPluginView = pluginDashboardViews.some((entry) => entry.pluginId === "fusion-plugin-roadmap"); const roadmapEnabled = Boolean(experimentalFeatures?.roadmap) && !hasRoadmapsPluginView; const skillsEnabled = Boolean(showSkillsTab); const todoViewEnabled = Boolean(experimentalFeatures?.todoView); // Keep a maximum of one optional primary tab visible at once to preserve touch-target width. // Overflowed destinations remain available in the More sheet. const showRoadmapsTopLevel = roadmapEnabled && (!skillsEnabled || view === "roadmaps"); const showSkillsTopLevel = skillsEnabled && (!roadmapEnabled || view !== "roadmaps"); const showSkillsInMore = skillsEnabled && !showSkillsTopLevel; const isDependencyGraphView = (entry: PluginDashboardViewEntry): boolean => ( entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph" ); const sortedPrimaryPluginViews = pluginDashboardViews .filter((entry) => entry.view.placement === "primary") .sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER)); const dependencyGraphPluginView = pluginDashboardViews.find(isDependencyGraphView) ?? null; // Keep plugin-provided top-level tabs constrained on mobile so fixed tabs retain // reasonable touch-target width. Additional primary plugin destinations overflow into More. // FN-3235: Always surface the dependency graph destination as the first plugin top-level tab // on mobile so task graph navigation has a clear entry point. const prioritizedPrimaryPluginViews = dependencyGraphPluginView ? [dependencyGraphPluginView, ...sortedPrimaryPluginViews.filter((entry) => !isDependencyGraphView(entry))] : sortedPrimaryPluginViews; const MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS = 1; const topLevelPrimaryPluginViews = prioritizedPrimaryPluginViews.slice(0, MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS); const topLevelPluginViewKeys = new Set( topLevelPrimaryPluginViews.map((entry) => `${entry.pluginId}:${entry.view.viewId}`), ); const overflowPluginViews = pluginDashboardViews .filter((entry) => !topLevelPluginViewKeys.has(`${entry.pluginId}:${entry.view.viewId}`)) .sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER)); const isMoreActive = view === "documents" || view === "research" || view === "insights" || view === "memory" || view === "devserver" || view === "dev-server" || (todosOpen && todoViewEnabled) || (view === "roadmaps" && !showRoadmapsTopLevel) || (view === "skills" && !showSkillsTopLevel) || (view.startsWith("plugin:") && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view)); return ( <> {isMoreOpen && ( <>
Navigate
{isScriptsSubmenuOpen && (
{scriptsLoading ? (
Loading scripts…
) : scriptEntries.length > 0 ? ( <> {scriptEntries.map(([name, command]) => ( ))} {onOpenScripts && ( )} ) : ( onOpenScripts && ( ) )}
)} {showSkillsInMore && ( )} {roadmapEnabled && ( )} {experimentalFeatures?.researchView && ( )} {experimentalFeatures?.insights && ( )} {experimentalFeatures?.memoryView && ( )} {experimentalFeatures?.devServerView && ( )} {experimentalFeatures?.nodesView && onOpenNodes && ( )} {todoViewEnabled && ( )} {overflowPluginViews.map((entry) => { const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId); const PluginIcon = getPluginNavIcon(entry.view.icon); return ( ); })}
)} ); }