1297 lines
56 KiB
TypeScript
1297 lines
56 KiB
TypeScript
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<ProjectStatus, { color: string }> = {
|
|
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 (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
|
|
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<HTMLButtonElement>(null);
|
|
const overflowMenuRef = useRef<HTMLDivElement>(null);
|
|
const mobileSearchRef = useRef<HTMLDivElement>(null);
|
|
const mobileSearchInputRef = useRef<HTMLInputElement>(null);
|
|
const nodeSelectorRef = useRef<HTMLDivElement>(null);
|
|
const mobileProjectSwitchRef = useRef<HTMLDivElement>(null);
|
|
const viewOverflowRef = useRef<HTMLDivElement>(null);
|
|
const viewOverflowTriggerRef = useRef<HTMLButtonElement>(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 (
|
|
<div className="header-wrapper">
|
|
<header className="header" data-shell-kind={shellHost.kind}>
|
|
<div className="header-left">
|
|
<div className="header-brand">
|
|
<svg
|
|
className="header-logo"
|
|
width={24}
|
|
height={24}
|
|
viewBox="0 0 128 128"
|
|
fill="none"
|
|
aria-label={t("header.fusionLogo")}
|
|
role="img"
|
|
>
|
|
<circle
|
|
cx="64"
|
|
cy="64"
|
|
r="52"
|
|
stroke="currentColor"
|
|
strokeWidth="8"
|
|
/>
|
|
<path
|
|
d="M26 101C44 82 62 64 82 45C90 37 98 30 104 24C96 35 89 47 81 60C70 79 57 95 43 108C38 112 32 108 26 101Z"
|
|
fill="currentColor"
|
|
/>
|
|
</svg>
|
|
<h1 className="logo">{t("appName", "Fusion")}</h1>
|
|
</div>
|
|
|
|
{/* Mobile Project Switch - dropdown trigger next to logo when at least one project exists (mobile only) */}
|
|
{isMobile && projects.length >= 1 && onSelectProject && (
|
|
<div className="mobile-project-switch" ref={mobileProjectSwitchRef}>
|
|
<button
|
|
className={`mobile-project-switch-trigger${isMobileProjectSwitchOpen ? " mobile-project-switch-trigger--open" : ""}`}
|
|
onClick={() => setIsMobileProjectSwitchOpen((prev) => !prev)}
|
|
title={t("header.switchProject", "Switch project")}
|
|
aria-label={t("header.switchProject", "Switch project")}
|
|
aria-expanded={isMobileProjectSwitchOpen}
|
|
aria-haspopup="listbox"
|
|
data-testid="mobile-project-switch-trigger"
|
|
>
|
|
<ChevronDown size={14} className={`mobile-project-switch-chevron${isMobileProjectSwitchOpen ? " mobile-project-switch-chevron--open" : ""}`} />
|
|
</button>
|
|
{isMobileProjectSwitchOpen && (
|
|
<div
|
|
className="mobile-project-switch-dropdown"
|
|
role="listbox"
|
|
aria-label={t("header.selectProject", "Select project")}
|
|
data-testid="mobile-project-switch-dropdown"
|
|
>
|
|
{projects.map((project) => {
|
|
const isCurrent = currentProject?.id === project.id;
|
|
const statusColor = PROJECT_STATUS_CONFIG[project.status]?.color;
|
|
return (
|
|
<button
|
|
key={project.id}
|
|
className={`mobile-project-switch-item${isCurrent ? " mobile-project-switch-item--current" : ""}`}
|
|
onClick={() => {
|
|
onSelectProject(project);
|
|
setIsMobileProjectSwitchOpen(false);
|
|
}}
|
|
role="option"
|
|
aria-selected={isCurrent}
|
|
data-testid={`mobile-project-switch-item-${project.id}`}
|
|
>
|
|
<span
|
|
className="mobile-project-switch-dot"
|
|
style={{ backgroundColor: statusColor || "var(--text-muted)" }}
|
|
/>
|
|
<div className="mobile-project-switch-info">
|
|
<span className="mobile-project-switch-name">{project.name}</span>
|
|
<span className="mobile-project-switch-path">
|
|
{getTrailingPath(project.path, 2)}
|
|
</span>
|
|
</div>
|
|
{isCurrent && <Check size={14} className="mobile-project-switch-check" />}
|
|
</button>
|
|
);
|
|
})}
|
|
{onViewAllProjects && (
|
|
<>
|
|
<div className="mobile-project-switch-divider" />
|
|
<button
|
|
className="mobile-project-switch-manage"
|
|
onClick={() => {
|
|
onViewAllProjects();
|
|
setIsMobileProjectSwitchOpen(false);
|
|
}}
|
|
data-testid="mobile-project-switch-view-all"
|
|
>
|
|
<Grid3X3 size={14} />
|
|
<span>{t("header.viewProjects", "View Projects")}</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{hideFullNav && (
|
|
<div
|
|
id="header-workflow-slot"
|
|
className="header-workflow-slot header-workflow-slot--mobile"
|
|
data-testid="header-workflow-slot"
|
|
/>
|
|
)}
|
|
|
|
{/* Project Selector - Back button when project selected, dropdown when 2+ projects (tablet + desktop) */}
|
|
{!isMobile && projects.length >= 1 && onViewAllProjects && (
|
|
<StandaloneProjectSelector
|
|
projects={projects}
|
|
currentProject={currentProject ?? null}
|
|
onViewAll={onViewAllProjects}
|
|
onSelect={onSelectProject}
|
|
allowSingleProject
|
|
viewAllLabel={t("header.manageProjects", "Manage Projects")}
|
|
/>
|
|
)}
|
|
|
|
{/* Node selector and status indicator */}
|
|
{showNodeSelector && (
|
|
<div
|
|
className={`header-node-selector${isMobile ? " header-node-selector--mobile" : ""}`}
|
|
ref={isMobile ? undefined : nodeSelectorRef}
|
|
>
|
|
{/* Node status indicator - always visible */}
|
|
<NodeStatusIndicator
|
|
node={currentNode ?? null}
|
|
showDetails={!isMobile}
|
|
/>
|
|
|
|
{/* Node selector dropdown - desktop/tablet only */}
|
|
{!isMobile && (
|
|
<>
|
|
<button
|
|
className={`btn-icon node-selector-trigger${isNodeSelectorOpen ? " node-selector-trigger--open" : ""}`}
|
|
onClick={() => setIsNodeSelectorOpen((prev) => !prev)}
|
|
title={t("header.switchNode", "Switch node")}
|
|
aria-label={t("header.switchNode", "Switch node")}
|
|
aria-expanded={isNodeSelectorOpen}
|
|
aria-haspopup="listbox"
|
|
data-testid="node-selector-trigger"
|
|
>
|
|
<ChevronRight
|
|
size={12}
|
|
className={`node-selector-chevron${isNodeSelectorOpen ? " node-selector-chevron--open" : ""}`}
|
|
/>
|
|
</button>
|
|
|
|
{/* Node selector dropdown menu */}
|
|
{isNodeSelectorOpen && (
|
|
<div className="node-selector-dropdown" role="listbox" aria-label={t("header.selectNode", "Select node")}>
|
|
{/* Local option */}
|
|
<button
|
|
className={`node-selector-option${!isRemote ? " node-selector-option--active" : ""}`}
|
|
onClick={() => {
|
|
onSelectNode?.(null);
|
|
setIsNodeSelectorOpen(false);
|
|
}}
|
|
role="option"
|
|
aria-selected={!isRemote}
|
|
data-testid="node-option-local"
|
|
>
|
|
<NodeHealthDot status="online" compact />
|
|
<span className="node-selector-option-label">{t("header.localNode", "Local")}</span>
|
|
</button>
|
|
|
|
{/* Remote nodes */}
|
|
{remoteNodes.map((node) => (
|
|
<button
|
|
key={node.id}
|
|
className={`node-selector-option${currentNode?.id === node.id ? " node-selector-option--active" : ""}`}
|
|
onClick={() => {
|
|
onSelectNode?.(node);
|
|
setIsNodeSelectorOpen(false);
|
|
}}
|
|
role="option"
|
|
aria-selected={currentNode?.id === node.id}
|
|
data-testid={`node-option-${node.id}`}
|
|
>
|
|
<NodeHealthDot status={node.status} compact />
|
|
<span className="node-selector-option-label">{node.name}</span>
|
|
<span className="node-selector-option-status">{node.status}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="header-actions">
|
|
{shellConnectionControl}
|
|
{/* Mobile View Toggle - compact board/list switcher in header when mobile nav is active */}
|
|
{hideFullNav && onChangeView && (view === "board" || view === "list") && (
|
|
<div className="view-toggle" data-testid="mobile-view-toggle">
|
|
<button
|
|
className={`view-toggle-btn${view === "board" ? " active" : ""}`}
|
|
onClick={() => onChangeView("board")}
|
|
title={t("header.boardView", "Board view")}
|
|
aria-label={t("header.boardView", "Board view")}
|
|
aria-pressed={view === "board"}
|
|
data-testid="mobile-view-toggle-board"
|
|
>
|
|
<LayoutGrid size={16} />
|
|
</button>
|
|
<button
|
|
className={`view-toggle-btn${view === "list" ? " active" : ""}`}
|
|
onClick={() => onChangeView("list")}
|
|
title={t("header.listView", "List view")}
|
|
aria-label={t("header.listView", "List view")}
|
|
aria-pressed={view === "list"}
|
|
data-testid="mobile-view-toggle-list"
|
|
>
|
|
<List size={16} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mobile Search Trigger - only on mobile, show trigger button in header */}
|
|
{onSearchChange && isMobile && (hideFullNav || view === "board" || view === "list") && !shouldShowMobileSearch && (
|
|
<button
|
|
className="btn-icon mobile-search-trigger"
|
|
onClick={handleMobileSearchToggle}
|
|
title={t("header.openSearch", "Open search")}
|
|
aria-label={t("header.openSearch", "Open search")}
|
|
aria-expanded={false}
|
|
data-testid="mobile-header-search-btn"
|
|
>
|
|
<Search size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/* Usage button on mobile when mobile bottom nav is active */}
|
|
{isMobile && hideFullNav && onOpenUsage && (
|
|
<button
|
|
className="btn-icon"
|
|
onClick={(event) => onOpenUsage(event.currentTarget.getBoundingClientRect())}
|
|
title={t("header.viewUsage", "View usage")}
|
|
data-testid="mobile-header-usage-btn"
|
|
>
|
|
<Activity size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{hideHeaderViewNav && (
|
|
<div
|
|
id="header-workflow-slot"
|
|
className="header-workflow-slot"
|
|
data-testid="header-workflow-slot"
|
|
/>
|
|
)}
|
|
|
|
{/**
|
|
* 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 && (
|
|
<button
|
|
className="btn-icon"
|
|
onClick={handleNonMobileSearchToggle}
|
|
title={t("header.openSearch", "Open search")}
|
|
aria-label={t("header.openSearch", "Open search")}
|
|
data-testid="desktop-header-search-btn"
|
|
>
|
|
<Search size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/* View Toggle - always inline, even on mobile */}
|
|
{!hideFullNav && !hideHeaderViewNav && onChangeView && (
|
|
<div className="view-toggle">
|
|
<button
|
|
className={`view-toggle-btn${view === "board" ? " active" : ""}`}
|
|
onClick={() => onChangeView("board")}
|
|
title={t("header.boardView", "Board view")}
|
|
aria-label={t("header.boardView", "Board view")}
|
|
aria-pressed={view === "board"}
|
|
>
|
|
<LayoutGrid size={16} />
|
|
</button>
|
|
<button
|
|
className={`view-toggle-btn${view === "list" ? " active" : ""}`}
|
|
onClick={() => onChangeView("list")}
|
|
title={t("header.listView", "List view")}
|
|
aria-label={t("header.listView", "List view")}
|
|
aria-pressed={view === "list"}
|
|
>
|
|
<List size={16} />
|
|
</button>
|
|
{showAgentsTab && (
|
|
<button
|
|
className={`view-toggle-btn${view === "agents" ? " active" : ""}`}
|
|
onClick={() => onChangeView("agents")}
|
|
title={t("header.agentsView", "Agents view")}
|
|
aria-label={t("header.agentsView", "Agents view")}
|
|
aria-pressed={view === "agents"}
|
|
>
|
|
<Bot size={16} />
|
|
</button>
|
|
)}
|
|
{/*
|
|
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.
|
|
*/}
|
|
<button
|
|
className={`view-toggle-btn${view === "command-center" ? " active" : ""}`}
|
|
onClick={() => onChangeView("command-center")}
|
|
title={t("header.commandCenterView", "Dashboard")}
|
|
aria-label={t("header.commandCenterView", "Dashboard")}
|
|
aria-pressed={view === "command-center"}
|
|
data-testid="view-toggle-command-center"
|
|
>
|
|
<Gauge size={16} />
|
|
</button>
|
|
<button
|
|
className={`view-toggle-btn${view === "missions" ? " active" : ""}`}
|
|
onClick={() => onChangeView("missions")}
|
|
title={t("header.missionsView", "Missions view")}
|
|
aria-label={t("header.missionsView", "Missions view")}
|
|
aria-pressed={view === "missions"}
|
|
>
|
|
<Target size={16} />
|
|
</button>
|
|
<button
|
|
className={`view-toggle-btn${view === "chat" ? " active" : ""}`}
|
|
onClick={() => onChangeView("chat")}
|
|
title={t("header.chatView", "Chat view")}
|
|
aria-label={t("header.chatView", "Chat view")}
|
|
aria-pressed={view === "chat"}
|
|
data-testid="header-chat-view-btn"
|
|
>
|
|
<MessageSquare size={16} />
|
|
{chatHasUnreadResponse && view !== "chat" && (
|
|
<span className="status-dot status-dot--pending header-chat-unread-dot" aria-label={t("header.unreadChatResponse", "Unread chat response")} />
|
|
)}
|
|
</button>
|
|
{!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.
|
|
*/
|
|
<button
|
|
className={`view-toggle-btn${view === "documents" ? " active" : ""}`}
|
|
onClick={() => onChangeView("documents")}
|
|
title={t("header.documentsView", "Artifacts view")}
|
|
aria-label={t("header.documentsView", "Artifacts view")}
|
|
aria-pressed={view === "documents"}
|
|
>
|
|
<FileText size={16} />
|
|
</button>
|
|
)}
|
|
<button
|
|
className={`view-toggle-btn${view === "mailbox" ? " active" : ""}`}
|
|
onClick={() => (onOpenMailbox ? onOpenMailbox() : onChangeView("mailbox"))}
|
|
title={t("header.mailboxView", "Mailbox view")}
|
|
aria-label={t("header.mailboxView", "Mailbox view")}
|
|
aria-pressed={view === "mailbox"}
|
|
>
|
|
<Mail size={16} />
|
|
{view !== "mailbox" && mailboxPendingApprovalCount > 0 ? (
|
|
<span className="status-dot status-dot--pending header-chat-unread-dot" aria-label={t("header.pendingApprovals", "Pending approvals")} />
|
|
) : view !== "mailbox" && mailboxUnreadCount > 0 ? (
|
|
<span
|
|
className="status-dot status-dot--online header-chat-unread-dot"
|
|
aria-label={t("header.unreadMessages", "{{count}} unread messages", { count: mailboxUnreadCount })}
|
|
/>
|
|
) : null}
|
|
</button>
|
|
{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 (
|
|
<button
|
|
key={`${entry.pluginId}:${entry.view.viewId}`}
|
|
className={`view-toggle-btn${view === pluginTaskView || (view === "graph" && entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph") ? " active" : ""}`}
|
|
onClick={() => onChangeView(entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph" ? "graph" : pluginTaskView)}
|
|
title={`${entry.view.label} view`}
|
|
aria-label={`${entry.view.label} view`}
|
|
aria-pressed={view === pluginTaskView}
|
|
data-testid={`view-toggle-plugin-${entry.pluginId}-${entry.view.viewId}`}
|
|
>
|
|
<PluginIcon size={16} />
|
|
</button>
|
|
);
|
|
})}
|
|
{hasViewOverflowItems && (
|
|
<>
|
|
<button
|
|
ref={viewOverflowTriggerRef}
|
|
className={`view-toggle-btn${(["research", "skills", "insights", "memory", "secrets", "dev-server", "devserver", "graph", "todos"].includes(view) || (isTablet && view === "documents") || (experimentalFeatures?.evalsView && view === "evals") || (experimentalFeatures?.goalsView && view === "goalsView") || isPluginViewId(view)) ? " active" : ""}`}
|
|
onClick={() => {
|
|
setIsViewOverflowOpen((prev) => !prev);
|
|
}}
|
|
title={t("header.moreViews", "More views")}
|
|
aria-label={t("header.moreViews", "More views")}
|
|
aria-haspopup="menu"
|
|
aria-expanded={isViewOverflowOpen}
|
|
data-testid="view-toggle-overflow-trigger"
|
|
>
|
|
<ChevronDown size={12} />
|
|
</button>
|
|
{isViewOverflowOpen && (
|
|
<div
|
|
ref={viewOverflowRef}
|
|
className="view-toggle-overflow-menu"
|
|
role="menu"
|
|
aria-label={t("header.moreViews", "More views")}
|
|
>
|
|
{experimentalFeatures?.evalsView && (
|
|
<button
|
|
className={`view-toggle-overflow-item${view === "evals" ? " active" : ""}`}
|
|
onClick={() => {
|
|
onChangeView("evals");
|
|
setIsViewOverflowOpen(false);
|
|
}}
|
|
role="menuitem"
|
|
data-testid="view-overflow-evals"
|
|
>
|
|
<Target size={14} />
|
|
<span>{t("header.evalsView", "Evals")}</span>
|
|
</button>
|
|
)}
|
|
{experimentalFeatures?.goalsView && (
|
|
<button
|
|
className={`view-toggle-overflow-item${view === "goalsView" ? " active" : ""}`}
|
|
onClick={() => {
|
|
onChangeView("goalsView");
|
|
setIsViewOverflowOpen(false);
|
|
}}
|
|
role="menuitem"
|
|
data-testid="view-overflow-goals"
|
|
>
|
|
<Target size={14} />
|
|
<span>{t("header.goalsView", "Goals")}</span>
|
|
</button>
|
|
)}
|
|
{experimentalFeatures?.researchView && (
|
|
<button
|
|
className={`view-toggle-overflow-item${view === "research" ? " active" : ""}`}
|
|
onClick={() => {
|
|
onChangeView("research");
|
|
setIsViewOverflowOpen(false);
|
|
}}
|
|
role="menuitem"
|
|
data-testid="view-overflow-research"
|
|
>
|
|
<Search size={14} />
|
|
<span>{t("header.researchView", "Research")}</span>
|
|
</button>
|
|
)}
|
|
{experimentalFeatures?.insights && (
|
|
<button
|
|
className={`view-toggle-overflow-item${view === "insights" ? " active" : ""}`}
|
|
onClick={() => {
|
|
onChangeView("insights");
|
|
setIsViewOverflowOpen(false);
|
|
}}
|
|
role="menuitem"
|
|
data-testid="view-overflow-insights"
|
|
>
|
|
<Sparkles size={14} />
|
|
<span>{t("header.insightsView", "Insights")}</span>
|
|
</button>
|
|
)}
|
|
|
|
{showSkillsTab && (
|
|
<button
|
|
className={`view-toggle-overflow-item${view === "skills" ? " active" : ""}`}
|
|
onClick={() => {
|
|
onChangeView("skills");
|
|
setIsViewOverflowOpen(false);
|
|
}}
|
|
role="menuitem"
|
|
data-testid="view-overflow-skills"
|
|
>
|
|
<Zap size={14} />
|
|
<span>{t("header.skillsView", "Skills")}</span>
|
|
</button>
|
|
)}
|
|
{experimentalFeatures?.memoryView && (
|
|
<button
|
|
className={`view-toggle-overflow-item${view === "memory" ? " active" : ""}`}
|
|
onClick={() => {
|
|
onChangeView("memory");
|
|
setIsViewOverflowOpen(false);
|
|
}}
|
|
role="menuitem"
|
|
data-testid="view-toggle-memory"
|
|
>
|
|
<Brain size={14} />
|
|
<span>{t("header.memoryView", "Memory")}</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
className={`view-toggle-overflow-item${view === "secrets" ? " active" : ""}`}
|
|
onClick={() => {
|
|
onChangeView("secrets");
|
|
setIsViewOverflowOpen(false);
|
|
}}
|
|
role="menuitem"
|
|
data-testid="view-overflow-secrets"
|
|
>
|
|
<Lock size={14} />
|
|
<span>{t("header.secretsView", "Secrets")}</span>
|
|
</button>
|
|
{isTablet && (
|
|
<button
|
|
className={`view-toggle-overflow-item${view === "documents" ? " active" : ""}`}
|
|
onClick={() => {
|
|
onChangeView("documents");
|
|
setIsViewOverflowOpen(false);
|
|
}}
|
|
role="menuitem"
|
|
data-testid="view-overflow-documents"
|
|
>
|
|
<FileText size={14} />
|
|
<span>{t("header.documentsView", "Artifacts view")}</span>
|
|
</button>
|
|
)}
|
|
{experimentalFeatures?.devServerView && (
|
|
<button
|
|
className={`view-toggle-overflow-item${view === "dev-server" || view === "devserver" ? " active" : ""}`}
|
|
onClick={() => {
|
|
onChangeView("devserver");
|
|
setIsViewOverflowOpen(false);
|
|
}}
|
|
role="menuitem"
|
|
data-testid="view-toggle-devserver"
|
|
>
|
|
<Monitor size={14} />
|
|
<span>{t("header.devServerView", "Dev Server")}</span>
|
|
<span className="visually-hidden" data-testid="view-toggle-dev-server" />
|
|
</button>
|
|
)}
|
|
{todosEnabled && onChangeView && (
|
|
<button
|
|
className={`view-toggle-overflow-item${view === "todos" ? " active" : ""}`}
|
|
onClick={() => {
|
|
onChangeView("todos");
|
|
setIsViewOverflowOpen(false);
|
|
}}
|
|
role="menuitem"
|
|
data-testid="view-overflow-todos"
|
|
>
|
|
<CheckSquare size={14} />
|
|
<span>{t("header.todosView", "Todos")}</span>
|
|
</button>
|
|
)}
|
|
{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 (
|
|
<button
|
|
key={`${entry.pluginId}:${entry.view.viewId}`}
|
|
className={`view-toggle-overflow-item${view === pluginTaskView || (view === "graph" && entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph") ? " active" : ""}`}
|
|
onClick={() => {
|
|
onChangeView(entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph" ? "graph" : pluginTaskView);
|
|
setIsViewOverflowOpen(false);
|
|
}}
|
|
role="menuitem"
|
|
data-testid={`view-overflow-plugin-${entry.pluginId}-${entry.view.viewId}`}
|
|
>
|
|
<PluginIcon size={14} />
|
|
<span>{entry.view.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/*
|
|
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 && (
|
|
<button
|
|
className="btn-icon"
|
|
onClick={onOpenWorkflowEditor}
|
|
title={t("header.workflows", "Workflows")}
|
|
data-testid="workflow-steps-btn"
|
|
>
|
|
<Workflow size={16} />
|
|
</button>
|
|
)}
|
|
|
|
|
|
{/*
|
|
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.
|
|
<button className="btn-icon" onClick={() => onOpenSettings?.()} title={t("header.settings", "Settings")}>
|
|
<Settings size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/* Plugin UI slot for header actions */}
|
|
<PluginSlot slotId="header-action" projectId={projectId} />
|
|
|
|
{/*
|
|
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 && (
|
|
<button
|
|
className="btn-icon"
|
|
onClick={(event) => onOpenUsage(event.currentTarget.getBoundingClientRect())}
|
|
title={t("header.viewUsage", "View usage")}
|
|
aria-label={t("header.viewUsage", "View usage")}
|
|
data-testid="header-usage-btn"
|
|
>
|
|
<Activity size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/*
|
|
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 && (
|
|
<button
|
|
className={`btn-icon${rightDockOpen ? " btn-icon--active" : ""}`}
|
|
onClick={onToggleRightDock}
|
|
title={rightDockOpen ? t("header.hideRightSidebar", "Hide right sidebar") : t("header.showRightSidebar", "Show right sidebar")}
|
|
aria-label={rightDockOpen ? t("header.hideRightSidebar", "Hide right sidebar") : t("header.showRightSidebar", "Show right sidebar")}
|
|
aria-expanded={rightDockOpen}
|
|
aria-pressed={rightDockOpen}
|
|
data-testid="header-right-dock-toggle"
|
|
>
|
|
<PanelRight size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/* Compact overflow menu trigger (mobile only — tablet uses the right-sidebar toggle above) */}
|
|
{isMobile && !hideFullNav && (
|
|
<button
|
|
ref={overflowButtonRef}
|
|
className="btn-icon compact-overflow-trigger"
|
|
onClick={handleOverflowToggle}
|
|
title={t("header.moreHeaderActions", "More header actions")}
|
|
aria-label={t("header.moreHeaderActions", "More header actions")}
|
|
aria-expanded={isOverflowMenuOpen}
|
|
aria-haspopup="menu"
|
|
>
|
|
<MoreHorizontal size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/* Compact overflow menu (mobile only) */}
|
|
{isMobile && !hideFullNav && isOverflowMenuOpen && (
|
|
<div
|
|
ref={overflowMenuRef}
|
|
className="mobile-overflow-menu"
|
|
role="menu"
|
|
aria-label={t("header.additionalHeaderActions", "Additional header actions")}
|
|
>
|
|
{/* Projects - in overflow on mobile */}
|
|
{isMobile && projects.length >= 1 && onViewAllProjects && (
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={() => handleOverflowAction(onViewAllProjects)}
|
|
role="menuitem"
|
|
data-testid="overflow-project-selector-btn"
|
|
>
|
|
<Grid3X3 size={16} />
|
|
<span>{t("header.projects", "Projects")}</span>
|
|
</button>
|
|
)}
|
|
{/* Files - in overflow on mobile */}
|
|
{onOpenFiles && (
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={() => handleOverflowAction(onOpenFiles)}
|
|
role="menuitem"
|
|
data-testid="overflow-files-btn"
|
|
>
|
|
<Folder size={16} />
|
|
<span>{t("header.browseFiles", "Browse Files")}</span>
|
|
</button>
|
|
)}
|
|
{/* Git Manager - in overflow on mobile */}
|
|
{onOpenGitManager && (
|
|
<button
|
|
className="mobile-overflow-item mobile-overflow-item--with-badge"
|
|
onClick={() => handleOverflowAction(onOpenGitManager)}
|
|
role="menuitem"
|
|
data-testid="overflow-git-btn"
|
|
>
|
|
<GitBranch size={16} />
|
|
<span>{t("header.gitManager", "Git Manager")}</span>
|
|
{stashOrphanCount > 0 ? <span className="btn-badge">{stashOrphanCount}</span> : null}
|
|
</button>
|
|
)}
|
|
{!isDesktopShell && (
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={() => handleOverflowAction(onOpenGitHubImport)}
|
|
role="menuitem"
|
|
>
|
|
<GitHubLogo size={16} />
|
|
<span>{t("header.importFromGitHub", "Import from GitHub")}</span>
|
|
</button>
|
|
)}
|
|
{onOpenSchedules && (
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={() => handleOverflowAction(onOpenSchedules)}
|
|
role="menuitem"
|
|
data-testid="overflow-schedules-btn"
|
|
>
|
|
<Clock size={16} />
|
|
<span>{t("header.automation", "Automation")}</span>
|
|
</button>
|
|
)}
|
|
{onOpenActivityLog && (
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={() => handleOverflowAction(onOpenActivityLog)}
|
|
role="menuitem"
|
|
data-testid="overflow-activity-log-btn"
|
|
>
|
|
<History size={16} />
|
|
<span>{t("header.viewActivityLog", "View Activity Log")}</span>
|
|
</button>
|
|
)}
|
|
{onOpenMailbox && (
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={() => handleOverflowAction(onOpenMailbox)}
|
|
role="menuitem"
|
|
data-testid="overflow-mailbox-btn"
|
|
>
|
|
<Mail size={16} />
|
|
<span>{mailboxUnreadCount > 0 ? t("header.mailboxWithCount", "Mailbox ({{count}})", { count: mailboxUnreadCount }) : t("header.mailbox", "Mailbox")}</span>
|
|
{mailboxPendingApprovalCount > 0 && (
|
|
<span className="header-badge" data-testid="overflow-mailbox-approval-badge">{mailboxPendingApprovalCount}</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
{onOpenUsage && (
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={(event) =>
|
|
handleOverflowAction(() => onOpenUsage(event.currentTarget.getBoundingClientRect()))
|
|
}
|
|
role="menuitem"
|
|
data-testid="overflow-usage-btn"
|
|
>
|
|
<Activity size={16} />
|
|
<span>{t("header.viewUsage", "View Usage")}</span>
|
|
</button>
|
|
)}
|
|
{onOpenWorkflowEditor && (
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={() => handleOverflowAction(onOpenWorkflowEditor)}
|
|
role="menuitem"
|
|
data-testid="overflow-workflow-steps-btn"
|
|
>
|
|
<Workflow size={16} />
|
|
<span>{t("header.workflows", "Workflows")}</span>
|
|
</button>
|
|
)}
|
|
{/* Settings - always last in overflow menu */}
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={() => handleOverflowAction(onOpenSettings)}
|
|
role="menuitem"
|
|
>
|
|
<Settings size={16} />
|
|
<span>{t("header.settings", "Settings")}</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{/* Desktop/Tablet Search - floating below header, in board or list view */}
|
|
{canShowNonMobileSearch && shouldShowNonMobileSearch && (
|
|
<div className="header-floating-search">
|
|
<div className="header-search">
|
|
<Search size={14} className="header-search-icon" />
|
|
<input
|
|
autoFocus
|
|
type="text"
|
|
placeholder={t("header.searchTasks", "Search tasks...")}
|
|
value={searchQuery}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
className="header-search-input"
|
|
/>
|
|
<button
|
|
className="header-search-clear"
|
|
onClick={handleNonMobileSearchClose}
|
|
aria-label={t("header.closeSearch", "Close search")}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
{showBoardBranchFilters && (
|
|
<div className="header-branch-filters" data-testid="header-branch-filters-desktop">
|
|
<label className="header-branch-filter-label">
|
|
<span>{t("header.workingBranch", "Working branch")}</span>
|
|
<select
|
|
className="header-branch-filter-select"
|
|
value={branchFilter}
|
|
onChange={(event) => onBranchFilterChange?.(event.target.value)}
|
|
data-testid="working-branch-filter"
|
|
>
|
|
<option value="">{t("header.allWorkingBranches", "All working branches")}</option>
|
|
<option value={NO_BRANCH_FILTER_VALUE}>{t("header.noWorkingBranch", "No working branch")}</option>
|
|
{branchOptions.map((option) => (
|
|
<option key={option} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="header-branch-filter-label">
|
|
<span>{t("header.baseBranch", "Base branch")}</span>
|
|
<select
|
|
className="header-branch-filter-select"
|
|
value={baseBranchFilter}
|
|
onChange={(event) => onBaseBranchFilterChange?.(event.target.value)}
|
|
data-testid="target-branch-filter"
|
|
>
|
|
<option value="">{t("header.allBaseBranches", "All base branches")}</option>
|
|
<option value={NO_BRANCH_FILTER_VALUE}>{t("header.noBaseBranch", "No base branch")}</option>
|
|
{baseBranchOptions.map((option) => (
|
|
<option key={option} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Mobile Search Expanded - floating below header */}
|
|
{onSearchChange && isMobile && shouldShowMobileSearch && (
|
|
<div className="header-floating-search">
|
|
<div
|
|
ref={mobileSearchRef}
|
|
className="header-search mobile-search-expanded"
|
|
>
|
|
<Search size={14} className="header-search-icon" />
|
|
<input
|
|
ref={mobileSearchInputRef}
|
|
autoFocus
|
|
type="text"
|
|
placeholder={t("header.searchTasks", "Search tasks...")}
|
|
value={searchQuery}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
className="header-search-input"
|
|
/>
|
|
<button
|
|
className="header-search-clear"
|
|
onClick={handleMobileSearchClose}
|
|
aria-label={t("header.closeSearch", "Close search")}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
{showBoardBranchFilters && (
|
|
<div className="header-branch-filters" data-testid="header-branch-filters-mobile">
|
|
<label className="header-branch-filter-label">
|
|
<span>{t("header.workingBranch", "Working branch")}</span>
|
|
<select
|
|
className="header-branch-filter-select"
|
|
value={branchFilter}
|
|
onChange={(event) => onBranchFilterChange?.(event.target.value)}
|
|
data-testid="working-branch-filter-mobile"
|
|
>
|
|
<option value="">{t("header.allWorkingBranches", "All working branches")}</option>
|
|
<option value={NO_BRANCH_FILTER_VALUE}>{t("header.noWorkingBranch", "No working branch")}</option>
|
|
{branchOptions.map((option) => (
|
|
<option key={option} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="header-branch-filter-label">
|
|
<span>{t("header.baseBranch", "Base branch")}</span>
|
|
<select
|
|
className="header-branch-filter-select"
|
|
value={baseBranchFilter}
|
|
onChange={(event) => onBaseBranchFilterChange?.(event.target.value)}
|
|
data-testid="target-branch-filter-mobile"
|
|
>
|
|
<option value="">{t("header.allBaseBranches", "All base branches")}</option>
|
|
<option value={NO_BRANCH_FILTER_VALUE}>{t("header.noBaseBranch", "No base branch")}</option>
|
|
{baseBranchOptions.map((option) => (
|
|
<option key={option} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|