import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Server, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Map, Zap, Sparkles, FileText, Brain } from "lucide-react"; import type { ProjectInfo } from "../api"; import type { NodeConfig, ProjectStatus } from "@fusion/core"; import { fetchScripts } from "../api"; import { QuickScriptsDropdown } from "./QuickScriptsDropdown"; import { NodeStatusIndicator } from "./NodeStatusIndicator"; import { PluginSlot } from "./PluginSlot"; import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode"; export { useViewportMode }; // Status icon config for project selector dropdown const PROJECT_STATUS_CONFIG: Record = { active: { color: "var(--success)" }, paused: { color: "var(--warning)" }, errored: { color: "var(--color-error)" }, initializing: { color: "var(--info)" }, }; /** * ProjectSelector - A component for project navigation. * Shows "Back to All Projects" button with ChevronLeft icon when currentProject is set. * Shows project dropdown for switching when 2+ projects exist. */ function ProjectSelector({ projects, currentProject, onViewAll, onSelectProject, }: { projects: ProjectInfo[]; currentProject: ProjectInfo | null; onViewAll: () => void; onSelectProject?: (project: ProjectInfo) => void; }) { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); // Close dropdown on outside click useEffect(() => { if (!isOpen) return; const handleClickOutside = (e: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { setIsOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isOpen]); // Close on Escape useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { setIsOpen(false); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen]); const handleSelectProject = useCallback( (project: ProjectInfo) => { onSelectProject?.(project); setIsOpen(false); }, [onSelectProject] ); return (
{/* Back to All Projects button - shown when a project is selected */} {currentProject && ( )} {/* Project dropdown - shown when 2+ projects exist */} {projects.length > 1 && ( <> {isOpen && (
{projects.map((project) => { const isCurrent = currentProject?.id === project.id; const statusColor = PROJECT_STATUS_CONFIG[project.status]?.color; return ( ); })}
)} )}
); } // GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility function GitHubLogo({ size = 16 }: { size?: number }) { return ( ); } export interface HeaderProps { onOpenSettings?: () => void; onOpenGitHubImport?: () => void; onOpenPlanning?: () => void; /** Resume an in-flight planning session. Takes priority over onOpenPlanning when activePlanningSessionCount > 0 */ onResumePlanning?: () => void; /** Number of active planning sessions. When > 0, shows a badge on the Planning button. */ activePlanningSessionCount?: number; onOpenUsage?: () => void; onOpenActivityLog?: () => void; /** Opens the mailbox view */ onOpenMailbox?: () => void; /** Unread message count for badge display */ mailboxUnreadCount?: number; onOpenSchedules?: () => void; onOpenGitManager?: () => void; onOpenNodes?: () => void; /** When false, hides the Nodes management button. Defaults to true for backward compat. */ showNodesButton?: boolean; onOpenWorkflowSteps?: () => void; onOpenScripts?: () => void; onRunScript?: (name: string, command: string) => void; onToggleTerminal?: () => void; /** Opens the top-level workspace-aware file browser modal. */ onOpenFiles?: () => void; filesOpen?: boolean; globalPaused?: boolean; enginePaused?: boolean; onToggleGlobalPause?: () => void; onToggleEnginePause?: () => void; view?: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory"; onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory") => void; /** Whether to show the skills tab in the view toggle */ showSkillsTab?: boolean; /** When true, shows the Agents view tab button. Hidden by default (experimental feature). */ showAgentsTab?: boolean; searchQuery?: string; onSearchChange?: (query: string) => void; /** Multi-project props */ projects?: ProjectInfo[]; currentProject?: ProjectInfo | null; onSelectProject?: (project: ProjectInfo) => void; onViewAllProjects?: () => void; projectId?: string; isElectron?: boolean; /** When true, the mobile bottom nav bar handles primary navigation and header nav controls are hidden. */ mobileNavEnabled?: boolean; /** Available nodes for the node selector */ availableNodes?: NodeConfig[]; /** Currently selected node (null for local) */ currentNode?: NodeConfig | null; /** Callback when a node is selected */ onSelectNode?: (node: NodeConfig | null) => void; /** Whether the current view is a remote node */ isRemote?: boolean; /** Experimental feature flags controlling visibility of nav items. */ experimentalFeatures?: { insights?: boolean; roadmap?: boolean }; } export function Header({ onOpenSettings, onOpenGitHubImport, onOpenPlanning, onResumePlanning, activePlanningSessionCount = 0, onOpenUsage, onOpenActivityLog, onOpenMailbox, mailboxUnreadCount = 0, onOpenSchedules, onOpenGitManager, onOpenNodes, showNodesButton, onOpenWorkflowSteps, onOpenScripts, onRunScript, onToggleTerminal, onOpenFiles, filesOpen, globalPaused, enginePaused, onToggleGlobalPause, onToggleEnginePause, view = "board", onChangeView, showSkillsTab, showAgentsTab, searchQuery = "", onSearchChange, projects = [], currentProject, onSelectProject, onViewAllProjects, projectId, isElectron = false, mobileNavEnabled, availableNodes = [], currentNode, onSelectNode, isRemote = false, experimentalFeatures, }: HeaderProps) { const mode: ViewportMode = useViewportMode(); const isMobile = mode === "mobile"; const isTablet = mode === "tablet"; const isCompact = isMobile || isTablet; const hideFullNav = isMobile && mobileNavEnabled; const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); const [isNonMobileSearchOpen, setIsNonMobileSearchOpen] = useState(false); // Track when user has explicitly closed the search (used for toggle visibility) const [isNonMobileSearchExplicitlyClosed, setIsNonMobileSearchExplicitlyClosed] = useState(false); const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false); const [isTerminalSubmenuOpen, setIsTerminalSubmenuOpen] = useState(false); const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); const [isMobileProjectSwitchOpen, setIsMobileProjectSwitchOpen] = useState(false); const [isViewOverflowOpen, setIsViewOverflowOpen] = useState(false); const [isDesktopOverflowOpen, setIsDesktopOverflowOpen] = useState(false); const [overflowScripts, setOverflowScripts] = useState>({}); const [overflowScriptsLoading, setOverflowScriptsLoading] = useState(false); const overflowButtonRef = useRef(null); const overflowMenuRef = useRef(null); const desktopOverflowTriggerRef = useRef(null); const desktopOverflowRef = useRef(null); const mobileSearchRef = useRef(null); const mobileSearchInputRef = useRef(null); const terminalSubmenuOpenRef = useRef(false); const nodeSelectorRef = useRef(null); const mobileProjectSwitchRef = useRef(null); const viewOverflowRef = useRef(null); const viewOverflowTriggerRef = useRef(null); // Get remote nodes only (exclude local node type) const remoteNodes = useMemo(() => availableNodes.filter((node) => node.type === "remote"), [availableNodes] ); const showNodeSelector = remoteNodes.length > 0; // Script entries sorted alphabetically for overflow submenu const overflowScriptEntries = useMemo(() => { return Object.entries(overflowScripts).sort(([a], [b]) => a.localeCompare(b)); }, [overflowScripts]); // Keep ref in sync with state useEffect(() => { terminalSubmenuOpenRef.current = isTerminalSubmenuOpen; }, [isTerminalSubmenuOpen]); // Fetch scripts when terminal submenu opens in compact mode useEffect(() => { if (!isTerminalSubmenuOpen || !isCompact) return; let cancelled = false; setOverflowScriptsLoading(true); fetchScripts(projectId) .then((data) => { if (!cancelled) { setOverflowScripts(data); } }) .catch(() => { if (!cancelled) { setOverflowScripts({}); } }) .finally(() => { if (!cancelled) { setOverflowScriptsLoading(false); } }); return () => { cancelled = true; }; }, [isTerminalSubmenuOpen, isCompact, projectId]); // Keep mobile search open if there's an active search query const shouldShowMobileSearch = isMobileSearchOpen || searchQuery.length > 0; // Non-mobile search: toggled open OR has active query, but not if explicitly closed const shouldShowNonMobileSearch = (isNonMobileSearchOpen || searchQuery.length > 0) && !isNonMobileSearchExplicitlyClosed; // Show toggle when search is available, NOT currently shown, NOT explicitly closed, AND query is empty const canShowNonMobileSearchToggle = (view === "board" || view === "list") && !isMobile && onSearchChange && !isNonMobileSearchExplicitlyClosed && searchQuery.length === 0; const canShowNonMobileSearch = (view === "board" || view === "list") && !isMobile && onSearchChange; // Reset explicit close flag when query becomes empty (so toggle reappears) useEffect(() => { if (searchQuery === "") { setIsNonMobileSearchExplicitlyClosed(false); } }, [searchQuery]); // Close overflow menu on outside click useEffect(() => { if (!isOverflowMenuOpen) return; const handleClickOutside = (e: MouseEvent) => { if ( overflowMenuRef.current && !overflowMenuRef.current.contains(e.target as Node) && overflowButtonRef.current && !overflowButtonRef.current.contains(e.target as Node) ) { setIsOverflowMenuOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isOverflowMenuOpen]); // Close desktop overflow menu on outside click useEffect(() => { if (!isDesktopOverflowOpen) return; const handleClickOutside = (e: MouseEvent) => { if ( desktopOverflowRef.current && !desktopOverflowRef.current.contains(e.target as Node) && desktopOverflowTriggerRef.current && !desktopOverflowTriggerRef.current.contains(e.target as Node) ) { setIsDesktopOverflowOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isDesktopOverflowOpen]); // Close node selector on outside click useEffect(() => { if (!isNodeSelectorOpen) return; const handleClickOutside = (e: MouseEvent) => { if ( nodeSelectorRef.current && !nodeSelectorRef.current.contains(e.target as Node) ) { setIsNodeSelectorOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isNodeSelectorOpen]); // Close menus on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { setIsViewOverflowOpen(false); setIsDesktopOverflowOpen(false); if (terminalSubmenuOpenRef.current) { setIsTerminalSubmenuOpen(false); return; } setIsOverflowMenuOpen(false); setIsMobileSearchOpen(false); setIsNodeSelectorOpen(false); setIsMobileProjectSwitchOpen(false); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, []); // Close mobile project switch on outside click useEffect(() => { if (!isMobileProjectSwitchOpen) return; const handleClickOutside = (e: MouseEvent) => { if ( mobileProjectSwitchRef.current && !mobileProjectSwitchRef.current.contains(e.target as Node) ) { setIsMobileProjectSwitchOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isMobileProjectSwitchOpen]); // Close view toggle overflow on outside click useEffect(() => { if (!isViewOverflowOpen) return; const handleClickOutside = (e: MouseEvent) => { if ( viewOverflowRef.current && !viewOverflowRef.current.contains(e.target as Node) && viewOverflowTriggerRef.current && !viewOverflowTriggerRef.current.contains(e.target as Node) ) { setIsViewOverflowOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isViewOverflowOpen]); const handleMobileSearchToggle = useCallback(() => { setIsMobileSearchOpen((prev) => !prev); }, []); const handleNonMobileSearchToggle = useCallback(() => { setIsNonMobileSearchOpen(true); setIsNonMobileSearchExplicitlyClosed(false); }, []); const handleNonMobileSearchClose = useCallback(() => { setIsNonMobileSearchOpen(false); setIsNonMobileSearchExplicitlyClosed(true); if (onSearchChange) onSearchChange(""); }, [onSearchChange]); const handleOverflowToggle = useCallback(() => { setIsOverflowMenuOpen((prev) => !prev); }, []); const handleOverflowAction = useCallback((callback?: () => void) => { if (callback) callback(); setIsOverflowMenuOpen(false); setIsTerminalSubmenuOpen(false); }, []); const handleMobileSearchClose = useCallback(() => { setIsMobileSearchOpen(false); if (onSearchChange) onSearchChange(""); }, [onSearchChange]); return (

Fusion

{/* Mobile Project Switch - dropdown trigger next to logo when 2+ projects (mobile only) */} {isMobile && projects.length > 1 && onSelectProject && (
{isMobileProjectSwitchOpen && (
{projects.map((project) => { const isCurrent = currentProject?.id === project.id; const statusColor = PROJECT_STATUS_CONFIG[project.status]?.color; return ( ); })}
)}
)} {/* Project Selector - Back button when project selected, dropdown when 2+ projects (desktop only) */} {!isCompact && projects.length >= 1 && onViewAllProjects && ( )} {/* Node selector and status indicator */} {showNodeSelector && (
{/* Node status indicator - always visible */} {/* Node selector dropdown - desktop/tablet only */} {!isMobile && ( <> {/* Node selector dropdown menu */} {isNodeSelectorOpen && (
{/* Local option */} {/* Remote nodes */} {remoteNodes.map((node) => ( ))}
)} )}
)}
{/* Mobile View Toggle - compact board/list switcher in header when mobile nav is active */} {hideFullNav && onChangeView && (view === "board" || view === "list") && (
)} {/* Mobile Search Trigger - only on mobile, show trigger button in header */} {onSearchChange && isMobile && (hideFullNav || view === "board" || view === "list") && !shouldShowMobileSearch && ( )} {/* Desktop/Tablet Search Toggle - show icon when search is available but hidden */} {canShowNonMobileSearchToggle && ( )} {/* Usage button on mobile when mobile bottom nav is active */} {isMobile && hideFullNav && onOpenUsage && ( )} {/* View Toggle - always inline, even on mobile */} {!hideFullNav && onChangeView && (
{showAgentsTab && ( )} {isViewOverflowOpen && (
{experimentalFeatures?.insights && ( )} {experimentalFeatures?.roadmap && ( )} {showSkillsTab && ( )}
)}
)} {/* Usage button - desktop only (moved to overflow on mobile/tablet) */} {!isCompact && onOpenUsage && ( )} {/* Activity Log button - desktop only (moved to overflow on mobile/tablet) */} {!isCompact && onOpenActivityLog && ( )} {/* Mailbox button - desktop only */} {!isCompact && onOpenMailbox && ( )} {/* Desktop actions */} {!isCompact && !isElectron && ( )} {!isCompact && ( )} {/* Terminal button - desktop only (moved to overflow on mobile/tablet) */} {!isCompact && ( )} {/* Files button - desktop only (moved to overflow on mobile/tablet) */} {!isCompact && onOpenFiles && ( )} {/* Git Manager button - desktop only (moved to overflow on mobile/tablet) */} {!isCompact && onOpenGitManager && ( )} {/* Workflow Steps - desktop only (moved to overflow on mobile/tablet) */} {!isCompact && onOpenWorkflowSteps && ( )} {/* Scripts - desktop only (moved to overflow on mobile/tablet) */} {!isCompact && onOpenScripts && onRunScript && ( )} {/* Desktop overflow menu for Nodes and Schedules */} {!isCompact && (
{isDesktopOverflowOpen && (
{onOpenNodes && showNodesButton !== false && ( )}
)}
)} {/* Pause button (soft pause) - always inline */} {/* Stop button (hard stop) - always inline */} {/* Settings - always inline on desktop, placed after engine controls */} {!isCompact && ( )} {/* Plugin UI slot for header actions */} {/* Compact overflow menu trigger (mobile + tablet) */} {isCompact && !hideFullNav && ( )} {/* Compact overflow menu (mobile + tablet) */} {isCompact && !hideFullNav && isOverflowMenuOpen && (
{/* Projects - in overflow on mobile */} {projects.length >= 1 && onViewAllProjects && ( )} {/* Files - in overflow on mobile */} {onOpenFiles && ( )} {/* Git Manager - in overflow on mobile */} {onOpenGitManager && ( )} {/* Nodes - in overflow on mobile */} {onOpenNodes && showNodesButton !== false && ( )} {!isElectron && ( )}
{isTerminalSubmenuOpen && (
{overflowScriptsLoading ? (
Loading scripts…
) : overflowScriptEntries.length > 0 ? ( <> {overflowScriptEntries.map(([name, command]) => ( ))} {onOpenScripts && ( )} ) : ( onOpenScripts && ( ) )}
)}
{/* Activity Log - in overflow on mobile */} {onOpenActivityLog && ( )} {/* Mailbox - in overflow on mobile */} {onOpenMailbox && ( )} {/* Usage - in overflow on mobile */} {onOpenUsage && ( )} {/* Workflow Steps - in overflow on mobile */} {onOpenWorkflowSteps && ( )} {/* Settings - always last in overflow menu */}
)}
{/* Desktop/Tablet Search - floating below header, in board or list view */} {canShowNonMobileSearch && shouldShowNonMobileSearch && (
onSearchChange(e.target.value)} className="header-search-input" />
)} {/* Mobile Search Expanded - floating below header */} {onSearchChange && isMobile && shouldShowMobileSearch && (
onSearchChange(e.target.value)} className="header-search-input" />
)}
); }