Files
fusion/packages/dashboard/app/components/Header.tsx
gsxdsm 147398f2c8 FN-8595: add mobile project favorite controls
Group mobile project switcher favorites ahead of other projects.

- Reuse the shared bookmark store for mobile project rows and toggles.
- Add responsive favorite-control styling and coverage for grouping, empty states, and selection behavior.
- Add a release changeset for the mobile favorite-project experience.

Files changed:
 .changeset/mobile-project-favorites.md             |   7 ++
 packages/dashboard/app/components/Header.tsx       | 120 +++++++++++++++------
 .../dashboard/app/components/ProjectSelector.css   |  40 +++++++
 .../Header.mobile-project-favorites.test.tsx       | 116 ++++++++++++++++++++
 4 files changed, 253 insertions(+), 30 deletions(-)

Fusion-Task-Id: FN-8595

Fusion-Task-Lineage: 1f739ee9-1ff5-4b47-ac9e-7e4a5857f1a5

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-25 21:57:15 -07:00

1384 lines
60 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, Lightbulb, ChevronDown, ChevronRight, PanelRight, Star } from "lucide-react";
import "./Header.css";
// ProjectSelector styles used by the imported standalone component.
import "./ProjectSelector.css";
import { ProjectSelector as StandaloneProjectSelector } from "./ProjectSelector";
import { useProjectBookmarks } from "../hooks/useProjectBookmarks";
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 { resolveReportContextRefs } from "../utils/reportContextRefs";
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; ideationView?: 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 { bookmarkedIds, toggleBookmark, isBookmarked } = useProjectBookmarks();
/*
FNXC:ProjectSelector 2026-08-26-00:00:
Mobile project switching must separate favorites at the top while sharing the desktop localStorage bookmark store. Preserve the incoming order within each section so grouping never changes the project's canonical ordering.
*/
const mobileProjectGroups = useMemo(() => {
const favorites = projects.filter((project) => bookmarkedIds.has(project.id));
const others = projects.filter((project) => !bookmarkedIds.has(project.id));
return { favorites, others };
}, [bookmarkedIds, projects]);
/*
FNXC:ProjectSelector 2026-08-26-00:00:
Mobile rows use the same localStorage bookmark toggle as desktop. Stop propagation so bookmarking never selects a project or closes the switcher.
*/
const renderMobileProjectItem = (project: ProjectInfo) => {
const isCurrent = currentProject?.id === project.id;
const bookmarked = isBookmarked(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>
<span
role="button"
tabIndex={0}
className={`mobile-project-switch-bookmark${bookmarked ? " bookmarked" : ""}`}
onClick={(event) => {
event.stopPropagation();
toggleBookmark(project.id);
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
toggleBookmark(project.id);
}
}}
aria-label={bookmarked
? t("projectSelector.removeBookmark", "Remove bookmark")
: t("projectSelector.addBookmark", "Bookmark project")}
data-testid={`mobile-bookmark-toggle-${project.id}`}
>
<Star size={14} fill={bookmarked ? "currentColor" : "none"} />
</span>
{isCurrent && <Check size={14} className="mobile-project-switch-check" />}
</button>
);
};
const hasViewOverflowItems = useMemo(() => {
return !!(
onChangeView ||
experimentalFeatures?.researchView ||
experimentalFeatures?.ideationView ||
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;
const canShowNonMobileSearch = (view === "board" || view === "list") && !isMobile && onSearchChange;
// Non-mobile search: toggled open OR has active query, but not if explicitly closed.
const shouldShowNonMobileSearch = (isNonMobileSearchOpen || searchQuery.length > 0) && !isNonMobileSearchExplicitlyClosed;
/*
FNXC:BoardSearch 2026-07-01-23:38:
Closing board/list search must suppress the populated floating panel until App clears searchQuery, then immediately restore the Open search affordance. Keep the explicit-close state out of the empty-query toggle gate so an open-but-empty dismissal cannot strand the header without a search trigger.
*/
const canShowNonMobileSearchToggle = Boolean(canShowNonMobileSearch && !shouldShowNonMobileSearch && searchQuery.length === 0);
const showBoardBranchFilters = view === "board";
// Reset explicit close flag when query becomes empty (so active-query reopen behavior is ready for the next search).
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"
>
{mobileProjectGroups.favorites.length > 0 && (
<div data-testid="mobile-project-switch-favorites">
<div className="mobile-project-switch-section-label">
{t("header.favoriteProjects", "Favorites")}
</div>
{mobileProjectGroups.favorites.map(renderMobileProjectItem)}
</div>
)}
{mobileProjectGroups.favorites.length > 0 && mobileProjectGroups.others.length > 0 && (
<>
<div className="mobile-project-switch-divider" />
<div className="mobile-project-switch-section-label">
{t("header.allProjects", "All projects")}
</div>
</>
)}
{mobileProjectGroups.others.length > 0 && (
<div data-testid="mobile-project-switch-others">
{mobileProjectGroups.others.map(renderMobileProjectItem)}
</div>
)}
{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", "ideation", "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?.ideationView && (
<button
className={`view-toggle-overflow-item${view === "ideation" ? " active" : ""}`}
onClick={() => {
onChangeView("ideation");
setIsViewOverflowOpen(false);
}}
role="menuitem"
data-testid="view-overflow-ideation"
>
<Lightbulb size={14} />
<span>{t("nav.ideation", "Ideation")}</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.
*/}
{/*
FNXC:ReportPipeline 2026-07-18-19:25:
FN-8348 relocates guided Bug, Feedback, Idea, and Help reporting from
Header to Settings General and Command Center. Those always-reachable
destinations replace this responsive action-row slot without leaving a
compact-navigation dependency or an empty header control.
*/}
{!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>
);
}