import { useState, useEffect, useRef, useCallback, useMemo, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Settings, LayoutGrid, List, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, Grid3X3, Mail, MessageSquare, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge, ChevronDown, ChevronRight, PanelRight } from "lucide-react"; import "./Header.css"; // ProjectSelector styles used by the imported standalone component. import "./ProjectSelector.css"; import { ProjectSelector as StandaloneProjectSelector } from "./ProjectSelector"; import type { ProjectInfo } from "../api"; import type { NodeConfig, ProjectStatus } from "@fusion/core"; import { NodeStatusIndicator } from "./NodeStatusIndicator"; import { NodeHealthDot } from "./NodeHealthDot"; import { PluginSlot } from "./PluginSlot"; import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode"; import { getTrailingPath } from "../utils/pathDisplay"; import type { TaskView } from "../hooks/useViewState"; import type { PluginDashboardViewEntry } from "../api"; import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry"; import { getPluginNavIcon } from "./pluginNavIcon"; import type { ShellHostContext } from "../shell-host"; export { useViewportMode }; const NO_BRANCH_FILTER_VALUE = "__fusion:no-branch__"; // 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)" }, }; // Inline ProjectSelector removed — now imports StandaloneProjectSelector from ./ProjectSelector // which has scroll fix, autocomplete, and bookmarking features. // GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility function GitHubLogo({ size = 16 }: { size?: number }) { return ( ); } export interface HeaderProps { onOpenSettings?: () => void; onOpenGitHubImport?: () => void; onOpenUsage?: (anchorRect?: DOMRect | null) => void; onOpenActivityLog?: () => void; /** Opens the mailbox view */ onOpenMailbox?: () => void; /** Unread message count for badge display */ mailboxUnreadCount?: number; /** Pending approval count for mailbox indicator */ mailboxPendingApprovalCount?: number; /** Whether chat has an unread assistant response */ chatHasUnreadResponse?: boolean; /** Count of orphaned merger autostashes for stash recovery indicator. */ stashOrphanCount?: number; onOpenSchedules?: () => void; onOpenGitManager?: () => void; onOpenWorkflowEditor?: () => void; /** Opens the top-level workspace-aware file browser modal. */ onOpenFiles?: () => void; filesOpen?: boolean; todosEnabled?: boolean; view?: TaskView; onChangeView?: (view: TaskView) => 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; branchFilter?: string; baseBranchFilter?: string; branchOptions?: string[]; baseBranchOptions?: string[]; onBranchFilterChange?: (value: string) => void; onBaseBranchFilterChange?: (value: string) => void; /** Multi-project props */ projects?: ProjectInfo[]; currentProject?: ProjectInfo | null; onSelectProject?: (project: ProjectInfo) => void; onViewAllProjects?: () => void; projectId?: string; shellHost?: ShellHostContext; /** When true, the mobile bottom nav bar handles primary navigation and header nav controls are hidden. */ mobileNavEnabled?: boolean; /** When true on non-mobile screens, persistent left sidebar owns primary view navigation. */ leftSidebarNavActive?: boolean; /* FNXC:Navigation 2026-06-22-00:00: The right dock is no longer a persistent rail. On non-mobile surfaces the Header owns a single show/hide toggle (replacing the tablet three-dots overflow) that opens/closes the right sidebar; mobile keeps its existing overflow menu untouched. */ /** Whether the right dock is available on this surface (non-mobile + enabled). */ rightDockAvailable?: boolean; /** Current open state of the right dock. */ rightDockOpen?: boolean; /** Toggle the right dock open/closed. */ onToggleRightDock?: () => void; /** 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; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean; evalsView?: boolean; goalsView?: boolean; leftSidebarNav?: boolean; rightDock?: boolean }; pluginDashboardViews?: PluginDashboardViewEntry[]; shellConnectionControl?: ReactNode; } export function Header({ onOpenSettings, onOpenGitHubImport, onOpenUsage, onOpenActivityLog, onOpenMailbox, mailboxUnreadCount = 0, mailboxPendingApprovalCount = 0, chatHasUnreadResponse = false, stashOrphanCount = 0, onOpenSchedules, onOpenGitManager, onOpenWorkflowEditor, onOpenFiles, todosEnabled, view = "board", onChangeView, showSkillsTab, showAgentsTab, searchQuery = "", onSearchChange, branchFilter = "", baseBranchFilter = "", branchOptions = [], baseBranchOptions = [], onBranchFilterChange, onBaseBranchFilterChange, projects = [], currentProject, onSelectProject, onViewAllProjects, projectId, shellHost = { kind: "browser" }, mobileNavEnabled, leftSidebarNavActive = false, rightDockAvailable = false, rightDockOpen = false, onToggleRightDock, availableNodes = [], currentNode, onSelectNode, isRemote = false, experimentalFeatures, pluginDashboardViews = [], shellConnectionControl, }: HeaderProps) { const { t } = useTranslation("app"); const mode: ViewportMode = useViewportMode(); const isMobile = mode === "mobile"; const isTablet = mode === "tablet"; const isCompact = isMobile || isTablet; const hideFullNav = isMobile && mobileNavEnabled; /* FNXC:Navigation 2026-06-19-00:00: When experimental left sidebar navigation is active on tablet/desktop, Header must suppress its view-toggle and More-views trigger so there is one canonical non-mobile navigation surface and no orphaned chevron remains. FNXC:WorkflowControls 2026-06-20-00:00: The hidden Header view-toggle location becomes the workflow-control portal slot only when left sidebar navigation is active on tablet/desktop. Mobile and flag-off paths keep workflow controls inline so the board/list chrome remains byte-identical. FNXC:WorkflowControls 2026-06-22-18:00: Mobile also renders the workflow portal in the top header next to the logo/project switch. The board/list workflow selector stays single-sourced through this slot, while CSS hides the "Workflow" label and compacts the trigger so it fits the mobile header. */ const hideHeaderViewNav = leftSidebarNavActive && !isMobile; /* FNXC:Navigation 2026-06-21-23:40: The right dock is persistent and owns its own collapse control, so Header must not render a duplicate right-dock toggle or repurpose the More views overflow trigger on tablet/desktop. */ 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 [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); const [isMobileProjectSwitchOpen, setIsMobileProjectSwitchOpen] = useState(false); const [isViewOverflowOpen, setIsViewOverflowOpen] = useState(false); const overflowButtonRef = useRef(null); const overflowMenuRef = useRef(null); const mobileSearchRef = useRef(null); const mobileSearchInputRef = useRef(null); 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; const hasViewOverflowItems = useMemo(() => { return !!( onChangeView || experimentalFeatures?.researchView || todosEnabled || experimentalFeatures?.insights || showSkillsTab || experimentalFeatures?.memoryView || experimentalFeatures?.devServerView || !hideFullNav || isTablet || pluginDashboardViews.some((entry) => entry.view.placement !== "primary") ); }, [onChangeView, experimentalFeatures, todosEnabled, showSkillsTab, hideFullNav, isTablet, pluginDashboardViews]); // 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; const showBoardBranchFilters = view === "board"; // 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 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); 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); }, []); const handleMobileSearchClose = useCallback(() => { setIsMobileSearchOpen(false); if (onSearchChange) onSearchChange(""); }, [onSearchChange]); const isDesktopShell = shellHost.kind === "desktop-shell"; return (

{t("appName", "Fusion")}

{/* Mobile Project Switch - dropdown trigger next to logo when at least one project exists (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 ( ); })} {onViewAllProjects && ( <>
)}
)}
)} {hideFullNav && (
)} {/* Project Selector - Back button when project selected, dropdown when 2+ projects (tablet + desktop) */} {!isMobile && 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) => ( ))}
)} )}
)}
{shellConnectionControl} {/* 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 && ( )} {/* Usage button on mobile when mobile bottom nav is active */} {isMobile && hideFullNav && onOpenUsage && ( )} {hideHeaderViewNav && (
)} {/** * FNXC:Header 2026-06-21-00:00: * Desktop and tablet header search must render after the workflow portal slot so a populated WorkflowSwitcher appears left of the search icon while preserving the mobile search trigger's existing position and behavior. */} {canShowNonMobileSearchToggle && ( )} {/* View Toggle - always inline, even on mobile */} {!hideFullNav && !hideHeaderViewNav && onChangeView && (
{showAgentsTab && ( )} {/* FNXC:Navigation 2026-06-19-12:00: FN-6781 supersedes the prior tablet-only inline / desktop-overflow split: Command Center must remain a stable inline destination immediately after Agents on tablet and desktop so the affordance does not relocate while resizing. Documents still moves to the tablet More-views overflow to conserve horizontal space without changing desktop ordering. */} {!isTablet && ( /* FNXC:Navigation 2026-06-21-18:25: The top-level documents destination now displays as Artifacts (FN-6890), but the documents route id remains stable for navigation and tests. */ )} {pluginDashboardViews .filter((entry) => entry.view.placement === "primary") .sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER)) .map((entry) => { const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId); const PluginIcon = getPluginNavIcon(entry.view.icon); return ( ); })} {hasViewOverflowItems && ( <> {isViewOverflowOpen && (
{experimentalFeatures?.evalsView && ( )} {experimentalFeatures?.goalsView && ( )} {experimentalFeatures?.researchView && ( )} {experimentalFeatures?.insights && ( )} {showSkillsTab && ( )} {experimentalFeatures?.memoryView && ( )} {isTablet && ( )} {experimentalFeatures?.devServerView && ( )} {todosEnabled && onChangeView && ( )} {pluginDashboardViews .filter((entry) => entry.view.placement !== "primary") .sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER)) .map((entry) => { const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId); const PluginIcon = getPluginNavIcon(entry.view.icon); return ( ); })}
)} )}
)} {/* FNXC:Navigation 2026-06-21-20:20: FN-6882 moves desktop tool actions (Activity, Activity Log, GitHub Import, Git Manager, Files, Automation) out of the Header toolbar into the right-dock tools rail while compact overflow keeps those tools for mobile/tablet. FNXC:Navigation 2026-06-21-00:00: FN-6886 removes the header Lightbulb affordances because Planning Mode is now a primary left-sidebar destination after Command Center and a single canonical MobileNavBar More item on compact breakpoints. */} {/* FNXC:Navigation 2026-06-22-00:00: When the left sidebar is active it owns Workflows as a main-content destination, so the Header drops its duplicate desktop Workflow button. The flag-off desktop layout keeps the Header button; mobile/tablet keep the overflow entry. */} {!isCompact && !leftSidebarNavActive && onOpenWorkflowEditor && ( )} {/* FNXC:Navigation 2026-06-21-13:48: Left sidebar navigation owns desktop Settings when active, so Header hides its duplicate icon to preserve a single titled Settings control for users and navigation-history tests. */} {!isCompact && !leftSidebarNavActive && ( // FNXC:Navigation 2026-06-22-12:00: Wrap so React's MouseEvent is not forwarded as onOpenSettings' settingsInitialSection arg. )} {/* Plugin UI slot for header actions */} {/* FNXC:Navigation 2026-06-22-00:50: Usage (Activity) lives in the top header to the left of the right-sidebar toggle and opens the UsageIndicator as a header-anchored modal (not inline in the dock). Non-mobile only; mobile keeps its own usage button in the bottom-nav layout. */} {!isMobile && onOpenUsage && ( )} {/* FNXC:Navigation 2026-06-22-00:00: Non-mobile surfaces (desktop + tablet) get a single right-sidebar show/hide toggle that owns the right dock visibility. It replaces the tablet three-dots overflow; the dock is fully hidden when closed and reopened from here. Mobile is intentionally excluded — it keeps its existing overflow menu untouched and has no right dock. */} {!isMobile && rightDockAvailable && onToggleRightDock && ( )} {/* Compact overflow menu trigger (mobile only — tablet uses the right-sidebar toggle above) */} {isMobile && !hideFullNav && ( )} {/* Compact overflow menu (mobile only) */} {isMobile && !hideFullNav && isOverflowMenuOpen && (
{/* Projects - in overflow on mobile */} {isMobile && projects.length >= 1 && onViewAllProjects && ( )} {/* Files - in overflow on mobile */} {onOpenFiles && ( )} {/* Git Manager - in overflow on mobile */} {onOpenGitManager && ( )} {!isDesktopShell && ( )} {onOpenSchedules && ( )} {onOpenActivityLog && ( )} {onOpenMailbox && ( )} {onOpenUsage && ( )} {onOpenWorkflowEditor && ( )} {/* 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" />
{showBoardBranchFilters && (
)}
)} {/* Mobile Search Expanded - floating below header */} {onSearchChange && isMobile && shouldShowMobileSearch && (
onSearchChange(e.target.value)} className="header-search-input" />
{showBoardBranchFilters && (
)}
)}
); }