Adds unassigned branch filter options to the dashboard header, allowing users to filter tasks by unassigned status. Updates the Header component with new filter controls and includes corresponding tests for the App and Header components. Fusion-Task-Id: FN-3424
2008 lines
77 KiB
TypeScript
2008 lines
77 KiB
TypeScript
import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
|
|
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare } from "lucide-react";
|
|
import "./Header.css";
|
|
// Header renders an inline ProjectSelector dropdown using project-selector-* classes.
|
|
import "./ProjectSelector.css";
|
|
import type { ProjectInfo } from "../api";
|
|
import type { NodeConfig, ProjectStatus } from "@fusion/core";
|
|
import { fetchScripts } from "../api";
|
|
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";
|
|
|
|
export { useViewportMode };
|
|
|
|
const NO_BRANCH_FILTER_VALUE = "__none__";
|
|
|
|
// 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)" },
|
|
};
|
|
|
|
/**
|
|
* ProjectSelector - A component for project navigation.
|
|
* Shows project dropdown for switching projects and navigating to project management.
|
|
*/
|
|
function ProjectSelector({
|
|
projects,
|
|
currentProject,
|
|
onViewAll,
|
|
onSelectProject,
|
|
}: {
|
|
projects: ProjectInfo[];
|
|
currentProject: ProjectInfo | null;
|
|
onViewAll: () => void;
|
|
onSelectProject?: (project: ProjectInfo) => void;
|
|
}) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Close dropdown on outside click
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [isOpen]);
|
|
|
|
// Close on Escape
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [isOpen]);
|
|
|
|
const handleSelectProject = useCallback(
|
|
(project: ProjectInfo) => {
|
|
onSelectProject?.(project);
|
|
setIsOpen(false);
|
|
},
|
|
[onSelectProject]
|
|
);
|
|
|
|
return (
|
|
<div className="project-selector" ref={dropdownRef}>
|
|
{projects.length > 0 && (
|
|
<>
|
|
<button
|
|
className={`project-selector-trigger${isOpen ? " project-selector-trigger--open" : ""}`}
|
|
onClick={() => setIsOpen((prev) => !prev)}
|
|
title={currentProject?.name ? `Switch project (current: ${currentProject.name})` : "Switch project"}
|
|
aria-label="Switch project"
|
|
aria-expanded={isOpen}
|
|
aria-haspopup="listbox"
|
|
data-testid="project-selector-trigger"
|
|
>
|
|
<span className="project-selector-trigger-label">
|
|
{currentProject?.name ?? "Projects"}
|
|
</span>
|
|
<ChevronDown size={12} className={`project-selector-chevron${isOpen ? " project-selector-chevron--open" : ""}`} />
|
|
</button>
|
|
{isOpen && (
|
|
<div
|
|
className="project-selector-dropdown"
|
|
role="listbox"
|
|
aria-label="Select project"
|
|
data-testid="project-selector-dropdown"
|
|
>
|
|
{projects.map((project) => {
|
|
const isCurrent = currentProject?.id === project.id;
|
|
const statusColor = PROJECT_STATUS_CONFIG[project.status]?.color;
|
|
return (
|
|
<button
|
|
key={project.id}
|
|
className={`project-selector-item${isCurrent ? " project-selector-item--current" : ""}`}
|
|
onClick={() => handleSelectProject(project)}
|
|
role="option"
|
|
aria-selected={isCurrent}
|
|
>
|
|
<span
|
|
className="project-selector-dot"
|
|
style={{ backgroundColor: statusColor || "var(--text-muted)" }}
|
|
/>
|
|
<div className="project-selector-info">
|
|
<span className="project-selector-name">{project.name}</span>
|
|
<span className="project-selector-path">
|
|
{getTrailingPath(project.path, 2)}
|
|
</span>
|
|
</div>
|
|
{isCurrent && <Check size={14} className="project-selector-check" />}
|
|
</button>
|
|
);
|
|
})}
|
|
<div className="project-selector-divider" role="presentation" />
|
|
<button
|
|
className="project-selector-manage"
|
|
onClick={() => {
|
|
onViewAll();
|
|
setIsOpen(false);
|
|
}}
|
|
data-testid="manage-projects-action"
|
|
>
|
|
Manage Projects
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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>
|
|
);
|
|
}
|
|
|
|
interface DropdownPosition {
|
|
top: number;
|
|
left: number;
|
|
width: number;
|
|
}
|
|
|
|
export interface HeaderProps {
|
|
onOpenSettings?: () => void;
|
|
onOpenGitHubImport?: () => void;
|
|
onOpenPlanning?: () => void;
|
|
/** Resume an in-flight planning session. Takes priority over onOpenPlanning when activePlanningSessionCount > 0 */
|
|
onResumePlanning?: () => void;
|
|
/** Number of active planning sessions. When > 0, shows a badge on the Planning button. */
|
|
activePlanningSessionCount?: number;
|
|
onOpenUsage?: (anchorRect?: DOMRect | null) => void;
|
|
onOpenActivityLog?: () => void;
|
|
onOpenSystemStats?: () => void;
|
|
/** Opens the mailbox view */
|
|
onOpenMailbox?: () => void;
|
|
/** Unread message count for badge display */
|
|
mailboxUnreadCount?: number;
|
|
/** Whether chat has an unread assistant response */
|
|
chatHasUnreadResponse?: boolean;
|
|
onOpenSchedules?: () => void;
|
|
onOpenGitManager?: () => void;
|
|
onOpenNodes?: () => void;
|
|
/** When false, hides the Nodes management button. Defaults to true for backward compat. */
|
|
showNodesButton?: boolean;
|
|
onOpenWorkflowSteps?: () => void;
|
|
onOpenScripts?: () => void;
|
|
onRunScript?: (name: string, command: string) => void;
|
|
onToggleTerminal?: () => void;
|
|
/** Opens the top-level workspace-aware file browser modal. */
|
|
onOpenFiles?: () => void;
|
|
filesOpen?: boolean;
|
|
onOpenTodos?: () => void;
|
|
todosOpen?: boolean;
|
|
todosEnabled?: boolean;
|
|
globalPaused?: boolean;
|
|
enginePaused?: boolean;
|
|
onToggleGlobalPause?: () => void;
|
|
onToggleEnginePause?: () => void;
|
|
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 | null;
|
|
baseBranchFilter?: string | null;
|
|
branchOptions?: string[];
|
|
baseBranchOptions?: string[];
|
|
onBranchFilterChange?: (value: string | null) => void;
|
|
onBaseBranchFilterChange?: (value: string | null) => void;
|
|
/** Multi-project props */
|
|
projects?: ProjectInfo[];
|
|
currentProject?: ProjectInfo | null;
|
|
onSelectProject?: (project: ProjectInfo) => void;
|
|
onViewAllProjects?: () => void;
|
|
projectId?: string;
|
|
isElectron?: boolean;
|
|
/** When true, the mobile bottom nav bar handles primary navigation and header nav controls are hidden. */
|
|
mobileNavEnabled?: boolean;
|
|
/** Available nodes for the node selector */
|
|
availableNodes?: NodeConfig[];
|
|
/** Currently selected node (null for local) */
|
|
currentNode?: NodeConfig | null;
|
|
/** Callback when a node is selected */
|
|
onSelectNode?: (node: NodeConfig | null) => void;
|
|
/** Whether the current view is a remote node */
|
|
isRemote?: boolean;
|
|
/** Experimental feature flags controlling visibility of nav items. */
|
|
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean };
|
|
pluginDashboardViews?: PluginDashboardViewEntry[];
|
|
shellConnectionControl?: ReactNode;
|
|
}
|
|
|
|
export function Header({
|
|
onOpenSettings,
|
|
onOpenGitHubImport,
|
|
onOpenPlanning,
|
|
onResumePlanning,
|
|
activePlanningSessionCount = 0,
|
|
onOpenUsage,
|
|
onOpenActivityLog,
|
|
onOpenSystemStats,
|
|
onOpenMailbox,
|
|
mailboxUnreadCount = 0,
|
|
chatHasUnreadResponse = false,
|
|
onOpenSchedules,
|
|
onOpenGitManager,
|
|
onOpenNodes,
|
|
showNodesButton,
|
|
onOpenWorkflowSteps,
|
|
onOpenScripts,
|
|
onRunScript,
|
|
onToggleTerminal,
|
|
onOpenFiles,
|
|
filesOpen,
|
|
onOpenTodos,
|
|
todosOpen,
|
|
todosEnabled,
|
|
globalPaused,
|
|
enginePaused,
|
|
onToggleGlobalPause,
|
|
onToggleEnginePause,
|
|
view = "board",
|
|
onChangeView,
|
|
showSkillsTab,
|
|
showAgentsTab,
|
|
searchQuery = "",
|
|
onSearchChange,
|
|
branchFilter = null,
|
|
baseBranchFilter = null,
|
|
branchOptions = [],
|
|
baseBranchOptions = [],
|
|
onBranchFilterChange,
|
|
onBaseBranchFilterChange,
|
|
projects = [],
|
|
currentProject,
|
|
onSelectProject,
|
|
onViewAllProjects,
|
|
projectId,
|
|
isElectron = false,
|
|
mobileNavEnabled,
|
|
availableNodes = [],
|
|
currentNode,
|
|
onSelectNode,
|
|
isRemote = false,
|
|
experimentalFeatures,
|
|
pluginDashboardViews = [],
|
|
shellConnectionControl,
|
|
}: HeaderProps) {
|
|
const mode: ViewportMode = useViewportMode();
|
|
const isMobile = mode === "mobile";
|
|
const isTablet = mode === "tablet";
|
|
const isCompact = isMobile || isTablet;
|
|
const hideFullNav = isMobile && mobileNavEnabled;
|
|
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
|
const [isNonMobileSearchOpen, setIsNonMobileSearchOpen] = useState(false);
|
|
// Track when user has explicitly closed the search (used for toggle visibility)
|
|
const [isNonMobileSearchExplicitlyClosed, setIsNonMobileSearchExplicitlyClosed] = useState(false);
|
|
const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false);
|
|
const [isTerminalSubmenuOpen, setIsTerminalSubmenuOpen] = useState(false);
|
|
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
|
const [isMobileProjectSwitchOpen, setIsMobileProjectSwitchOpen] = useState(false);
|
|
const [isViewOverflowOpen, setIsViewOverflowOpen] = useState(false);
|
|
const [isDesktopOverflowOpen, setIsDesktopOverflowOpen] = useState(false);
|
|
const [isEngineMenuOpen, setIsEngineMenuOpen] = useState(false);
|
|
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
|
|
const [scripts, setScripts] = useState<Record<string, string>>({});
|
|
const [scriptsLoading, setScriptsLoading] = useState(false);
|
|
const [highlightedScriptIndex, setHighlightedScriptIndex] = useState(-1);
|
|
const [scriptsDropdownPosition, setScriptsDropdownPosition] = useState<DropdownPosition | null>(null);
|
|
const [overflowScripts, setOverflowScripts] = useState<Record<string, string>>({});
|
|
const [overflowScriptsLoading, setOverflowScriptsLoading] = useState(false);
|
|
const overflowButtonRef = useRef<HTMLButtonElement>(null);
|
|
const overflowMenuRef = useRef<HTMLDivElement>(null);
|
|
const desktopOverflowTriggerRef = useRef<HTMLButtonElement>(null);
|
|
const desktopOverflowRef = useRef<HTMLDivElement>(null);
|
|
const mobileSearchRef = useRef<HTMLDivElement>(null);
|
|
const mobileSearchInputRef = useRef<HTMLInputElement>(null);
|
|
const terminalSubmenuOpenRef = useRef(false);
|
|
const nodeSelectorRef = useRef<HTMLDivElement>(null);
|
|
const mobileProjectSwitchRef = useRef<HTMLDivElement>(null);
|
|
const viewOverflowRef = useRef<HTMLDivElement>(null);
|
|
const viewOverflowTriggerRef = useRef<HTMLButtonElement>(null);
|
|
const engineMenuRef = useRef<HTMLDivElement>(null);
|
|
const scriptsSplitButtonRef = useRef<HTMLDivElement>(null);
|
|
const scriptsChevronButtonRef = useRef<HTMLButtonElement>(null);
|
|
const scriptsMenuRef = useRef<HTMLDivElement>(null);
|
|
const scriptsOpenRef = useRef(false);
|
|
|
|
// Get remote nodes only (exclude local node type)
|
|
const remoteNodes = useMemo(() =>
|
|
availableNodes.filter((node) => node.type === "remote"),
|
|
[availableNodes]
|
|
);
|
|
const showNodeSelector = remoteNodes.length > 0;
|
|
|
|
// Script entries sorted alphabetically for desktop scripts dropdown
|
|
const scriptEntries = useMemo(() => {
|
|
return Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b));
|
|
}, [scripts]);
|
|
|
|
const showScriptsFooter = scriptEntries.length > 0;
|
|
const totalScriptItems = scriptEntries.length + (showScriptsFooter ? 1 : 0);
|
|
|
|
// Script entries sorted alphabetically for overflow submenu
|
|
const overflowScriptEntries = useMemo(() => {
|
|
return Object.entries(overflowScripts).sort(([a], [b]) => a.localeCompare(b));
|
|
}, [overflowScripts]);
|
|
|
|
const hasRoadmapsPluginView = useMemo(
|
|
() => pluginDashboardViews.some((entry) => entry.pluginId === "fusion-plugin-roadmap"),
|
|
[pluginDashboardViews],
|
|
);
|
|
|
|
const hasViewOverflowItems = useMemo(() => {
|
|
return !!(
|
|
experimentalFeatures?.researchView ||
|
|
todosEnabled ||
|
|
experimentalFeatures?.insights ||
|
|
(experimentalFeatures?.roadmap && !hasRoadmapsPluginView) ||
|
|
showSkillsTab ||
|
|
experimentalFeatures?.memoryView ||
|
|
experimentalFeatures?.devServerView ||
|
|
!hideFullNav ||
|
|
pluginDashboardViews.some((entry) => entry.view.placement !== "primary")
|
|
);
|
|
}, [experimentalFeatures, todosEnabled, showSkillsTab, hideFullNav, pluginDashboardViews, hasRoadmapsPluginView]);
|
|
|
|
const getEffectiveViewport = useCallback(() => {
|
|
const vv = window.visualViewport;
|
|
if (vv && vv.width > 0 && vv.height > 0) {
|
|
return {
|
|
width: vv.width,
|
|
height: vv.height,
|
|
offsetTop: vv.offsetTop,
|
|
offsetLeft: vv.offsetLeft,
|
|
};
|
|
}
|
|
|
|
return {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
offsetTop: 0,
|
|
offsetLeft: 0,
|
|
};
|
|
}, []);
|
|
|
|
const updateScriptsDropdownPosition = useCallback(() => {
|
|
const trigger = scriptsChevronButtonRef.current;
|
|
if (!trigger) return;
|
|
|
|
const rect = trigger.getBoundingClientRect();
|
|
const menu = scriptsMenuRef.current;
|
|
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
|
|
const horizontalPadding = 16;
|
|
const verticalPadding = 16;
|
|
const gap = 6;
|
|
|
|
const measuredWidth = menu?.offsetWidth || Math.max(rect.width, 260);
|
|
const width = Math.min(
|
|
measuredWidth,
|
|
Math.max(viewportWidth - horizontalPadding * 2, 160),
|
|
);
|
|
|
|
const measuredHeight = menu?.offsetHeight || 280;
|
|
const constrainedHeight = Math.min(
|
|
measuredHeight,
|
|
Math.max(viewportHeight - verticalPadding * 2, 160),
|
|
);
|
|
|
|
const triggerTop = rect.top - offsetTop;
|
|
const triggerBottom = rect.bottom - offsetTop;
|
|
const triggerRight = rect.right - offsetLeft;
|
|
|
|
const spaceBelow = viewportHeight - triggerBottom;
|
|
const spaceAbove = triggerTop;
|
|
|
|
const openUpward = spaceBelow < constrainedHeight && spaceAbove > spaceBelow;
|
|
|
|
const left = Math.min(
|
|
Math.max(triggerRight - width, horizontalPadding),
|
|
viewportWidth - horizontalPadding - width,
|
|
) + offsetLeft;
|
|
|
|
const top = openUpward
|
|
? Math.max(verticalPadding + offsetTop, triggerTop - constrainedHeight - gap + offsetTop)
|
|
: Math.min(
|
|
triggerBottom + gap + offsetTop,
|
|
viewportHeight + offsetTop - verticalPadding - constrainedHeight,
|
|
);
|
|
|
|
setScriptsDropdownPosition({ top, left, width });
|
|
}, [getEffectiveViewport]);
|
|
|
|
const handleRunQuickScript = useCallback(
|
|
(name: string, command: string) => {
|
|
onRunScript?.(name, command);
|
|
setIsScriptsOpen(false);
|
|
setHighlightedScriptIndex(-1);
|
|
},
|
|
[onRunScript],
|
|
);
|
|
|
|
const handleManageScripts = useCallback(() => {
|
|
onOpenScripts?.();
|
|
setIsScriptsOpen(false);
|
|
setHighlightedScriptIndex(-1);
|
|
}, [onOpenScripts]);
|
|
|
|
const handleScriptsDropdownKeyDown = useCallback(
|
|
(e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
switch (e.key) {
|
|
case "ArrowDown":
|
|
e.preventDefault();
|
|
if (totalScriptItems > 0) {
|
|
setHighlightedScriptIndex((prev) => (prev < totalScriptItems - 1 ? prev + 1 : 0));
|
|
}
|
|
break;
|
|
case "ArrowUp":
|
|
e.preventDefault();
|
|
if (totalScriptItems > 0) {
|
|
setHighlightedScriptIndex((prev) => (prev > 0 ? prev - 1 : totalScriptItems - 1));
|
|
}
|
|
break;
|
|
case "Enter":
|
|
e.preventDefault();
|
|
if (highlightedScriptIndex >= 0) {
|
|
if (highlightedScriptIndex < scriptEntries.length) {
|
|
const [name, command] = scriptEntries[highlightedScriptIndex];
|
|
handleRunQuickScript(name, command);
|
|
} else if (showScriptsFooter && highlightedScriptIndex === scriptEntries.length) {
|
|
handleManageScripts();
|
|
}
|
|
}
|
|
break;
|
|
case "Home":
|
|
e.preventDefault();
|
|
if (totalScriptItems > 0) {
|
|
setHighlightedScriptIndex(0);
|
|
}
|
|
break;
|
|
case "End":
|
|
e.preventDefault();
|
|
if (totalScriptItems > 0) {
|
|
setHighlightedScriptIndex(totalScriptItems - 1);
|
|
}
|
|
break;
|
|
}
|
|
},
|
|
[handleManageScripts, handleRunQuickScript, highlightedScriptIndex, scriptEntries, showScriptsFooter, totalScriptItems],
|
|
);
|
|
|
|
// Keep ref in sync with state
|
|
useEffect(() => {
|
|
terminalSubmenuOpenRef.current = isTerminalSubmenuOpen;
|
|
}, [isTerminalSubmenuOpen]);
|
|
|
|
useEffect(() => {
|
|
scriptsOpenRef.current = isScriptsOpen;
|
|
}, [isScriptsOpen]);
|
|
|
|
// Fetch scripts when terminal submenu opens in compact mode
|
|
useEffect(() => {
|
|
if (!isTerminalSubmenuOpen || !isCompact) return;
|
|
|
|
let cancelled = false;
|
|
setOverflowScriptsLoading(true);
|
|
|
|
fetchScripts(projectId)
|
|
.then((data) => {
|
|
if (!cancelled) {
|
|
setOverflowScripts(data);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setOverflowScripts({});
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setOverflowScriptsLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [isTerminalSubmenuOpen, isCompact, projectId]);
|
|
|
|
// Fetch scripts when desktop scripts dropdown opens
|
|
useEffect(() => {
|
|
if (!isScriptsOpen || isCompact) return;
|
|
|
|
let cancelled = false;
|
|
setScriptsLoading(true);
|
|
|
|
fetchScripts(projectId)
|
|
.then((data) => {
|
|
if (!cancelled) {
|
|
setScripts(data);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setScripts({});
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setScriptsLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [isScriptsOpen, isCompact, projectId]);
|
|
|
|
// Close desktop scripts dropdown on outside click
|
|
useEffect(() => {
|
|
if (!isScriptsOpen) return;
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (
|
|
scriptsSplitButtonRef.current &&
|
|
!scriptsSplitButtonRef.current.contains(e.target as Node)
|
|
) {
|
|
setIsScriptsOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [isScriptsOpen]);
|
|
|
|
// Close desktop scripts dropdown on Escape
|
|
useEffect(() => {
|
|
if (!isScriptsOpen) return;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
setIsScriptsOpen(false);
|
|
scriptsChevronButtonRef.current?.focus();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [isScriptsOpen]);
|
|
|
|
// Reset highlight and focus menu when dropdown opens
|
|
useEffect(() => {
|
|
if (isScriptsOpen) {
|
|
setHighlightedScriptIndex(-1);
|
|
const timeoutId = window.setTimeout(() => scriptsMenuRef.current?.focus(), 0);
|
|
return () => window.clearTimeout(timeoutId);
|
|
}
|
|
|
|
setScriptsDropdownPosition(null);
|
|
}, [isScriptsOpen]);
|
|
|
|
// Position scripts dropdown when opening and content changes
|
|
useEffect(() => {
|
|
if (!isScriptsOpen) return;
|
|
|
|
const rafId = requestAnimationFrame(() => {
|
|
updateScriptsDropdownPosition();
|
|
});
|
|
|
|
return () => cancelAnimationFrame(rafId);
|
|
}, [isScriptsOpen, scriptsLoading, scriptEntries.length, showScriptsFooter, updateScriptsDropdownPosition]);
|
|
|
|
// Keep scripts dropdown anchored on viewport changes
|
|
useEffect(() => {
|
|
if (!isScriptsOpen) return;
|
|
|
|
const handleReposition = () => updateScriptsDropdownPosition();
|
|
|
|
window.addEventListener("resize", handleReposition);
|
|
window.addEventListener("scroll", handleReposition, true);
|
|
|
|
const vv = window.visualViewport;
|
|
if (vv) {
|
|
vv.addEventListener("resize", handleReposition);
|
|
vv.addEventListener("scroll", handleReposition);
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener("resize", handleReposition);
|
|
window.removeEventListener("scroll", handleReposition, true);
|
|
if (vv) {
|
|
vv.removeEventListener("resize", handleReposition);
|
|
vv.removeEventListener("scroll", handleReposition);
|
|
}
|
|
};
|
|
}, [isScriptsOpen, updateScriptsDropdownPosition]);
|
|
|
|
useEffect(() => {
|
|
if (isCompact) {
|
|
setIsScriptsOpen(false);
|
|
setHighlightedScriptIndex(-1);
|
|
}
|
|
}, [isCompact]);
|
|
|
|
// 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 desktop overflow menu on outside click
|
|
useEffect(() => {
|
|
if (!isDesktopOverflowOpen) return;
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (
|
|
desktopOverflowRef.current &&
|
|
!desktopOverflowRef.current.contains(e.target as Node) &&
|
|
desktopOverflowTriggerRef.current &&
|
|
!desktopOverflowTriggerRef.current.contains(e.target as Node)
|
|
) {
|
|
setIsDesktopOverflowOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [isDesktopOverflowOpen]);
|
|
|
|
// Close node selector on outside click
|
|
useEffect(() => {
|
|
if (!isNodeSelectorOpen) return;
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (
|
|
nodeSelectorRef.current &&
|
|
!nodeSelectorRef.current.contains(e.target as Node)
|
|
) {
|
|
setIsNodeSelectorOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [isNodeSelectorOpen]);
|
|
|
|
// Close menus on Escape key
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
setIsViewOverflowOpen(false);
|
|
setIsDesktopOverflowOpen(false);
|
|
if (terminalSubmenuOpenRef.current) {
|
|
setIsTerminalSubmenuOpen(false);
|
|
return;
|
|
}
|
|
if (scriptsOpenRef.current) {
|
|
setIsScriptsOpen(false);
|
|
scriptsChevronButtonRef.current?.focus();
|
|
return;
|
|
}
|
|
setIsOverflowMenuOpen(false);
|
|
setIsMobileSearchOpen(false);
|
|
setIsNodeSelectorOpen(false);
|
|
setIsMobileProjectSwitchOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, []);
|
|
|
|
// Close mobile project switch on outside click
|
|
useEffect(() => {
|
|
if (!isMobileProjectSwitchOpen) return;
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (
|
|
mobileProjectSwitchRef.current &&
|
|
!mobileProjectSwitchRef.current.contains(e.target as Node)
|
|
) {
|
|
setIsMobileProjectSwitchOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [isMobileProjectSwitchOpen]);
|
|
|
|
// Close engine controls dropdown on outside click
|
|
useEffect(() => {
|
|
if (!isEngineMenuOpen) return;
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (engineMenuRef.current && !engineMenuRef.current.contains(e.target as Node)) {
|
|
setIsEngineMenuOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [isEngineMenuOpen]);
|
|
|
|
// Close engine controls dropdown on Escape
|
|
useEffect(() => {
|
|
if (!isEngineMenuOpen) return;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setIsEngineMenuOpen(false);
|
|
};
|
|
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [isEngineMenuOpen]);
|
|
|
|
// Close view toggle overflow on outside click
|
|
useEffect(() => {
|
|
if (!isViewOverflowOpen) return;
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (
|
|
viewOverflowRef.current &&
|
|
!viewOverflowRef.current.contains(e.target as Node) &&
|
|
viewOverflowTriggerRef.current &&
|
|
!viewOverflowTriggerRef.current.contains(e.target as Node)
|
|
) {
|
|
setIsViewOverflowOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [isViewOverflowOpen]);
|
|
|
|
const handleMobileSearchToggle = useCallback(() => {
|
|
setIsMobileSearchOpen((prev) => !prev);
|
|
}, []);
|
|
|
|
const handleNonMobileSearchToggle = useCallback(() => {
|
|
setIsNonMobileSearchOpen(true);
|
|
setIsNonMobileSearchExplicitlyClosed(false);
|
|
}, []);
|
|
|
|
const handleNonMobileSearchClose = useCallback(() => {
|
|
setIsNonMobileSearchOpen(false);
|
|
setIsNonMobileSearchExplicitlyClosed(true);
|
|
if (onSearchChange) onSearchChange("");
|
|
}, [onSearchChange]);
|
|
|
|
const handleOverflowToggle = useCallback(() => {
|
|
setIsOverflowMenuOpen((prev) => !prev);
|
|
}, []);
|
|
|
|
const handleOverflowAction = useCallback((callback?: () => void) => {
|
|
if (callback) callback();
|
|
setIsOverflowMenuOpen(false);
|
|
setIsTerminalSubmenuOpen(false);
|
|
}, []);
|
|
|
|
const handleMobileSearchClose = useCallback(() => {
|
|
setIsMobileSearchOpen(false);
|
|
if (onSearchChange) onSearchChange("");
|
|
}, [onSearchChange]);
|
|
|
|
return (
|
|
<div className="header-wrapper">
|
|
<header className="header">
|
|
<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="Fusion logo"
|
|
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">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="Switch project"
|
|
aria-label="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="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>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Project Selector - Back button when project selected, dropdown when 2+ projects (desktop only) */}
|
|
{!isCompact && projects.length >= 1 && onViewAllProjects && (
|
|
<ProjectSelector
|
|
projects={projects}
|
|
currentProject={currentProject ?? null}
|
|
onViewAll={onViewAllProjects}
|
|
onSelectProject={onSelectProject}
|
|
/>
|
|
)}
|
|
|
|
{/* 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="Switch node"
|
|
aria-label="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="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">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="Board view"
|
|
aria-label="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="List view"
|
|
aria-label="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="Open search"
|
|
aria-label="Open search"
|
|
aria-expanded={false}
|
|
data-testid="mobile-header-search-btn"
|
|
>
|
|
<Search size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/* Desktop/Tablet Search Toggle - show icon when search is available but hidden */}
|
|
{canShowNonMobileSearchToggle && (
|
|
<button
|
|
className="btn-icon"
|
|
onClick={handleNonMobileSearchToggle}
|
|
title="Open search"
|
|
aria-label="Open search"
|
|
data-testid="desktop-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="View usage"
|
|
data-testid="mobile-header-usage-btn"
|
|
>
|
|
<Activity size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/* View Toggle - always inline, even on mobile */}
|
|
{!hideFullNav && onChangeView && (
|
|
<div className="view-toggle">
|
|
<button
|
|
className={`view-toggle-btn${view === "board" ? " active" : ""}`}
|
|
onClick={() => onChangeView("board")}
|
|
title="Board view"
|
|
aria-label="Board view"
|
|
aria-pressed={view === "board"}
|
|
>
|
|
<LayoutGrid size={16} />
|
|
</button>
|
|
<button
|
|
className={`view-toggle-btn${view === "list" ? " active" : ""}`}
|
|
onClick={() => onChangeView("list")}
|
|
title="List view"
|
|
aria-label="List view"
|
|
aria-pressed={view === "list"}
|
|
>
|
|
<List size={16} />
|
|
</button>
|
|
{showAgentsTab && (
|
|
<button
|
|
className={`view-toggle-btn${view === "agents" ? " active" : ""}`}
|
|
onClick={() => onChangeView("agents")}
|
|
title="Agents view"
|
|
aria-label="Agents view"
|
|
aria-pressed={view === "agents"}
|
|
>
|
|
<Bot size={16} />
|
|
</button>
|
|
)}
|
|
<button
|
|
className={`view-toggle-btn${view === "missions" ? " active" : ""}`}
|
|
onClick={() => onChangeView("missions")}
|
|
title="Missions view"
|
|
aria-label="Missions view"
|
|
aria-pressed={view === "missions"}
|
|
>
|
|
<Target size={16} />
|
|
</button>
|
|
<button
|
|
className={`view-toggle-btn${view === "chat" ? " active" : ""}`}
|
|
onClick={() => onChangeView("chat")}
|
|
title="Chat view"
|
|
aria-label="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="Unread chat response" />
|
|
)}
|
|
</button>
|
|
<button
|
|
className={`view-toggle-btn${view === "documents" ? " active" : ""}`}
|
|
onClick={() => onChangeView("documents")}
|
|
title="Documents view"
|
|
aria-label="Documents view"
|
|
aria-pressed={view === "documents"}
|
|
>
|
|
<FileText size={16} />
|
|
</button>
|
|
<button
|
|
className={`view-toggle-btn${view === "mailbox" ? " active" : ""}`}
|
|
onClick={() => onChangeView("mailbox")}
|
|
title="Mailbox view"
|
|
aria-label="Mailbox view"
|
|
aria-pressed={view === "mailbox"}
|
|
>
|
|
<Mail size={16} />
|
|
</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 ? " active" : ""}`}
|
|
onClick={() => onChangeView(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", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
|
|
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
|
title="More views"
|
|
aria-label="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="More views"
|
|
>
|
|
{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>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>Insights</span>
|
|
</button>
|
|
)}
|
|
{experimentalFeatures?.roadmap && !hasRoadmapsPluginView && (
|
|
<button
|
|
className={`view-toggle-overflow-item${view === "roadmaps" ? " active" : ""}`}
|
|
onClick={() => {
|
|
onChangeView("roadmaps");
|
|
setIsViewOverflowOpen(false);
|
|
}}
|
|
role="menuitem"
|
|
data-testid="view-overflow-roadmaps"
|
|
>
|
|
<span>Roadmaps</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>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>Memory</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>Dev Server</span>
|
|
<span className="visually-hidden" data-testid="view-toggle-dev-server" />
|
|
</button>
|
|
)}
|
|
{todosEnabled && onOpenTodos && (
|
|
<button
|
|
className={`view-toggle-overflow-item${todosOpen ? " active" : ""}`}
|
|
onClick={() => {
|
|
onOpenTodos();
|
|
setIsViewOverflowOpen(false);
|
|
}}
|
|
role="menuitem"
|
|
data-testid="view-overflow-todos"
|
|
>
|
|
<CheckSquare size={14} />
|
|
<span>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 ? " active" : ""}`}
|
|
onClick={() => {
|
|
onChangeView(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>
|
|
)}
|
|
|
|
{/* Usage button - desktop only (moved to overflow on mobile/tablet) */}
|
|
{!isCompact && onOpenUsage && (
|
|
<button
|
|
className="btn-icon"
|
|
onClick={(event) => onOpenUsage(event.currentTarget.getBoundingClientRect())}
|
|
title="View usage"
|
|
data-testid="desktop-header-usage-btn"
|
|
>
|
|
<Activity size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/* System Stats button - desktop only */}
|
|
{!isCompact && onOpenSystemStats && (
|
|
<button className="btn-icon" onClick={onOpenSystemStats} title="System Stats" data-testid="desktop-header-system-stats-btn">
|
|
<Monitor size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/* Activity Log button - desktop only (moved to overflow on mobile/tablet) */}
|
|
{!isCompact && onOpenActivityLog && (
|
|
<button className="btn-icon" onClick={onOpenActivityLog} title="View Activity Log">
|
|
<History size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/* Desktop actions */}
|
|
{!isCompact && !isElectron && (
|
|
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
|
|
<GitHubLogo size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{!isCompact && (
|
|
<button
|
|
className={`btn-icon${activePlanningSessionCount > 0 ? " btn-icon--has-indicator" : ""}`}
|
|
onClick={activePlanningSessionCount > 0 && onResumePlanning ? onResumePlanning : onOpenPlanning}
|
|
title={activePlanningSessionCount > 0 ? "Resume planning session" : "Create a task with AI planning"}
|
|
data-testid="planning-btn"
|
|
style={{ position: "relative" }}
|
|
>
|
|
<Lightbulb size={16} />
|
|
{activePlanningSessionCount > 0 && (
|
|
<span
|
|
className="header-badge header-badge--pulse"
|
|
data-testid="planning-badge"
|
|
aria-label={`${activePlanningSessionCount} active planning session${activePlanningSessionCount !== 1 ? "s" : ""}`}
|
|
>
|
|
{activePlanningSessionCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
|
|
{/* Terminal split button - desktop only (moved to overflow on mobile/tablet) */}
|
|
{!isCompact && (
|
|
<div className="terminal-split-btn" ref={scriptsSplitButtonRef}>
|
|
<button
|
|
className="btn-icon btn-icon--terminal terminal-split-btn__main"
|
|
onClick={onToggleTerminal}
|
|
title="Open Terminal"
|
|
data-testid="terminal-toggle-btn"
|
|
>
|
|
<Terminal size={16} />
|
|
</button>
|
|
{onOpenScripts && onRunScript && (
|
|
<>
|
|
<span className="terminal-split-btn__divider" />
|
|
<button
|
|
ref={scriptsChevronButtonRef}
|
|
className={`btn-icon terminal-split-btn__chevron${isScriptsOpen ? " btn-icon--active" : ""}`}
|
|
onClick={() => setIsScriptsOpen((prev) => !prev)}
|
|
title="Scripts"
|
|
aria-haspopup="listbox"
|
|
aria-expanded={isScriptsOpen}
|
|
aria-label="Quick scripts"
|
|
data-testid="scripts-btn"
|
|
>
|
|
<ChevronDown size={12} className={`quick-scripts-dropdown__trigger-chevron${isScriptsOpen ? " rotate" : ""}`} />
|
|
</button>
|
|
{isScriptsOpen && (
|
|
<div
|
|
ref={scriptsMenuRef}
|
|
tabIndex={-1}
|
|
className="quick-scripts-dropdown__menu"
|
|
role="listbox"
|
|
aria-label="Scripts"
|
|
onKeyDown={handleScriptsDropdownKeyDown}
|
|
data-testid="quick-scripts-dropdown"
|
|
style={
|
|
scriptsDropdownPosition
|
|
? {
|
|
position: "fixed",
|
|
top: `${scriptsDropdownPosition.top}px`,
|
|
left: `${scriptsDropdownPosition.left}px`,
|
|
width: `${scriptsDropdownPosition.width}px`,
|
|
right: "auto",
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{scriptsLoading ? (
|
|
<div className="quick-scripts-dropdown__loading" data-testid="quick-scripts-loading">
|
|
<Loader2 size={16} className="animate-spin" />
|
|
<span>Loading scripts...</span>
|
|
</div>
|
|
) : scriptEntries.length === 0 ? (
|
|
<div className="quick-scripts-dropdown__empty" data-testid="quick-scripts-empty">
|
|
<div className="quick-scripts-dropdown__empty-icon">
|
|
<Terminal size={16} />
|
|
</div>
|
|
<p>No scripts configured</p>
|
|
<button
|
|
className="quick-scripts-dropdown__empty-action btn"
|
|
onClick={handleManageScripts}
|
|
>
|
|
Add your first script
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="quick-scripts-dropdown__list">
|
|
{scriptEntries.map(([name, command], index) => (
|
|
<button
|
|
key={name}
|
|
className={`quick-scripts-dropdown__item ${
|
|
highlightedScriptIndex === index ? "highlighted" : ""
|
|
}`}
|
|
onClick={() => handleRunQuickScript(name, command)}
|
|
role="option"
|
|
aria-selected={highlightedScriptIndex === index}
|
|
data-testid={`quick-script-item-${name}`}
|
|
>
|
|
<Play size={14} className="quick-scripts-dropdown__item-icon" />
|
|
<div className="quick-scripts-dropdown__item-info">
|
|
<span className="quick-scripts-dropdown__item-name">{name}</span>
|
|
<span className="quick-scripts-dropdown__item-command" title={command}>
|
|
{command.length > 50 ? `${command.slice(0, 50)}...` : command}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="quick-scripts-dropdown__footer">
|
|
<button
|
|
className={`quick-scripts-dropdown__manage ${
|
|
showScriptsFooter && highlightedScriptIndex === scriptEntries.length ? "highlighted" : ""
|
|
}`}
|
|
onClick={handleManageScripts}
|
|
data-testid="quick-scripts-manage"
|
|
>
|
|
<Settings size={14} />
|
|
<span>Manage Scripts...</span>
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Files button - desktop only (moved to overflow on mobile/tablet) */}
|
|
{!isCompact && onOpenFiles && (
|
|
<button
|
|
className={`btn-icon${filesOpen ? " btn-icon--active" : ""}`}
|
|
onClick={onOpenFiles}
|
|
title="Browse files"
|
|
data-testid="files-toggle-btn"
|
|
>
|
|
<Folder size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/* Git Manager button - desktop only (moved to overflow on mobile/tablet) */}
|
|
{!isCompact && onOpenGitManager && (
|
|
<button
|
|
className="btn-icon"
|
|
onClick={onOpenGitManager}
|
|
title="Git Manager"
|
|
data-testid="git-manager-btn"
|
|
>
|
|
<GitBranch size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/* Workflow Steps - desktop only (moved to overflow on mobile/tablet) */}
|
|
{!isCompact && onOpenWorkflowSteps && (
|
|
<button
|
|
className="btn-icon"
|
|
onClick={onOpenWorkflowSteps}
|
|
title="Workflow Steps"
|
|
data-testid="workflow-steps-btn"
|
|
>
|
|
<Workflow size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/* Desktop overflow menu for Nodes and Schedules */}
|
|
{!isCompact && (
|
|
<div style={{ position: "relative" }}>
|
|
<button
|
|
ref={desktopOverflowTriggerRef}
|
|
className="btn-icon"
|
|
onClick={() => setIsDesktopOverflowOpen((prev) => !prev)}
|
|
title="More actions"
|
|
aria-label="More actions"
|
|
aria-expanded={isDesktopOverflowOpen}
|
|
aria-haspopup="menu"
|
|
data-testid="desktop-overflow-trigger"
|
|
>
|
|
<MoreHorizontal size={16} />
|
|
</button>
|
|
{isDesktopOverflowOpen && (
|
|
<div
|
|
ref={desktopOverflowRef}
|
|
className="desktop-overflow-menu"
|
|
role="menu"
|
|
aria-label="More actions"
|
|
>
|
|
{onOpenNodes && showNodesButton !== false && (
|
|
<button
|
|
className="view-toggle-overflow-item"
|
|
onClick={() => {
|
|
onOpenNodes();
|
|
setIsDesktopOverflowOpen(false);
|
|
}}
|
|
role="menuitem"
|
|
data-testid="desktop-overflow-nodes-btn"
|
|
>
|
|
<Server size={14} />
|
|
<span>Nodes</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
className="view-toggle-overflow-item"
|
|
onClick={() => {
|
|
handleOverflowAction(onOpenSchedules);
|
|
setIsDesktopOverflowOpen(false);
|
|
}}
|
|
role="menuitem"
|
|
data-testid="desktop-overflow-schedules-btn"
|
|
>
|
|
<Clock size={14} />
|
|
<span>Automation</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Engine control split-button: main=stop/start, chevron dropdown=pause triage */}
|
|
<div className="engine-control-split-btn" ref={engineMenuRef}>
|
|
<button
|
|
className={`btn-icon engine-control-split-btn__main${globalPaused ? " btn-icon--stopped" : ""}`}
|
|
onClick={onToggleGlobalPause}
|
|
title={globalPaused ? "Start AI engine" : "Stop AI engine"}
|
|
data-testid="engine-control-main-btn"
|
|
>
|
|
{globalPaused ? <Play size={16} /> : <Square size={16} />}
|
|
</button>
|
|
<span className="engine-control-split-btn__divider" />
|
|
<button
|
|
className={`btn-icon engine-control-split-btn__chevron${isEngineMenuOpen ? " btn-icon--active" : ""}`}
|
|
onClick={() => setIsEngineMenuOpen((prev) => !prev)}
|
|
title="Engine options"
|
|
aria-haspopup="menu"
|
|
aria-expanded={isEngineMenuOpen}
|
|
data-testid="engine-control-chevron-btn"
|
|
>
|
|
<ChevronDown size={12} />
|
|
</button>
|
|
{isEngineMenuOpen && (
|
|
<div className="engine-control-split-btn__menu" role="menu">
|
|
<button
|
|
className={`engine-control-split-btn__menu-item${enginePaused ? " engine-control-split-btn__menu-item--active" : ""}`}
|
|
onClick={() => {
|
|
onToggleEnginePause?.();
|
|
setIsEngineMenuOpen(false);
|
|
}}
|
|
role="menuitem"
|
|
title={enginePaused ? "Resume scheduling" : "Pause triage"}
|
|
disabled={!!globalPaused}
|
|
data-testid="engine-control-pause-triage-btn"
|
|
>
|
|
{enginePaused ? <Play size={14} /> : <Pause size={14} />}
|
|
<span>{enginePaused ? "Resume scheduling" : "Pause triage"}</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Settings - always inline on desktop, placed after engine controls */}
|
|
{!isCompact && (
|
|
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
|
|
<Settings size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/* Plugin UI slot for header actions */}
|
|
<PluginSlot slotId="header-action" projectId={projectId} />
|
|
|
|
{/* Compact overflow menu trigger (mobile + tablet) */}
|
|
{isCompact && !hideFullNav && (
|
|
<button
|
|
ref={overflowButtonRef}
|
|
className="btn-icon compact-overflow-trigger"
|
|
onClick={handleOverflowToggle}
|
|
title="More header actions"
|
|
aria-label="More header actions"
|
|
aria-expanded={isOverflowMenuOpen}
|
|
aria-haspopup="menu"
|
|
>
|
|
<MoreHorizontal size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/* Compact overflow menu (mobile + tablet) */}
|
|
{isCompact && !hideFullNav && isOverflowMenuOpen && (
|
|
<div
|
|
ref={overflowMenuRef}
|
|
className="mobile-overflow-menu"
|
|
role="menu"
|
|
aria-label="Additional header actions"
|
|
>
|
|
{/* Projects - in overflow on mobile */}
|
|
{projects.length >= 1 && onViewAllProjects && (
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={() => handleOverflowAction(onViewAllProjects)}
|
|
role="menuitem"
|
|
data-testid="overflow-project-selector-btn"
|
|
>
|
|
<Grid3X3 size={16} />
|
|
<span>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>Browse Files</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
className={`mobile-overflow-item${activePlanningSessionCount > 0 ? " mobile-overflow-item--has-indicator" : ""}`}
|
|
onClick={() => handleOverflowAction(activePlanningSessionCount > 0 && onResumePlanning ? onResumePlanning : onOpenPlanning)}
|
|
role="menuitem"
|
|
data-testid="overflow-planning-btn"
|
|
>
|
|
<span className="mobile-overflow-icon-wrapper">
|
|
<Lightbulb size={16} />
|
|
{activePlanningSessionCount > 0 && (
|
|
<span className="header-badge header-badge--pulse" data-testid="overflow-planning-badge">
|
|
{activePlanningSessionCount}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span>{activePlanningSessionCount > 0 ? `Resume planning session (${activePlanningSessionCount})` : "Create a task with AI planning"}</span>
|
|
</button>
|
|
{/* Git Manager - in overflow on mobile */}
|
|
{onOpenGitManager && (
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={() => handleOverflowAction(onOpenGitManager)}
|
|
role="menuitem"
|
|
data-testid="overflow-git-btn"
|
|
>
|
|
<GitBranch size={16} />
|
|
<span>Git Manager</span>
|
|
</button>
|
|
)}
|
|
{/* Nodes - in overflow on mobile */}
|
|
{onOpenNodes && showNodesButton !== false && (
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={() => handleOverflowAction(onOpenNodes)}
|
|
role="menuitem"
|
|
data-testid="overflow-nodes-btn"
|
|
>
|
|
<Server size={16} />
|
|
<span>Nodes</span>
|
|
</button>
|
|
)}
|
|
{!isElectron && (
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={() => handleOverflowAction(onOpenGitHubImport)}
|
|
role="menuitem"
|
|
>
|
|
<GitHubLogo size={16} />
|
|
<span>Import from GitHub</span>
|
|
</button>
|
|
)}
|
|
<div
|
|
className="mobile-overflow-group"
|
|
data-testid="overflow-terminal-group"
|
|
>
|
|
<div className="mobile-overflow-split-row">
|
|
<button
|
|
className="mobile-overflow-item mobile-overflow-split-primary"
|
|
onClick={() => handleOverflowAction(onToggleTerminal)}
|
|
role="menuitem"
|
|
data-testid="overflow-terminal-primary-btn"
|
|
>
|
|
<Terminal size={16} />
|
|
<span>Terminal</span>
|
|
</button>
|
|
<button
|
|
className="mobile-overflow-split-toggle"
|
|
onClick={() => setIsTerminalSubmenuOpen((prev) => !prev)}
|
|
role="menuitem"
|
|
aria-expanded={isTerminalSubmenuOpen}
|
|
aria-haspopup="menu"
|
|
aria-label="Show scripts"
|
|
data-testid="overflow-terminal-submenu-toggle"
|
|
>
|
|
<ChevronRight
|
|
size={14}
|
|
className={`mobile-overflow-chevron${isTerminalSubmenuOpen ? " mobile-overflow-chevron--open" : ""}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
{isTerminalSubmenuOpen && (
|
|
<div className="mobile-overflow-submenu" role="menu" aria-label="Scripts submenu">
|
|
{overflowScriptsLoading ? (
|
|
<div className="mobile-overflow-submenu-loading" data-testid="overflow-scripts-loading">
|
|
<Loader2 size={14} className="animate-spin" />
|
|
<span>Loading scripts…</span>
|
|
</div>
|
|
) : overflowScriptEntries.length > 0 ? (
|
|
<>
|
|
{overflowScriptEntries.map(([name, command]) => (
|
|
<button
|
|
key={name}
|
|
className="mobile-overflow-item mobile-overflow-subitem"
|
|
onClick={() => {
|
|
if (onRunScript) onRunScript(name, command);
|
|
setIsOverflowMenuOpen(false);
|
|
setIsTerminalSubmenuOpen(false);
|
|
}}
|
|
role="menuitem"
|
|
data-testid={`overflow-script-item-${name}`}
|
|
>
|
|
<Play size={14} />
|
|
<span>{name}</span>
|
|
</button>
|
|
))}
|
|
{onOpenScripts && (
|
|
<button
|
|
className="mobile-overflow-item mobile-overflow-subitem mobile-overflow-subitem--manage"
|
|
onClick={() => handleOverflowAction(onOpenScripts)}
|
|
role="menuitem"
|
|
data-testid="overflow-scripts-manage"
|
|
>
|
|
<FileCode size={14} />
|
|
<span>Manage Scripts…</span>
|
|
</button>
|
|
)}
|
|
</>
|
|
) : (
|
|
onOpenScripts && (
|
|
<button
|
|
className="mobile-overflow-item mobile-overflow-subitem"
|
|
onClick={() => handleOverflowAction(onOpenScripts)}
|
|
role="menuitem"
|
|
data-testid="overflow-scripts-manage"
|
|
>
|
|
<FileCode size={14} />
|
|
<span>No scripts — add one…</span>
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={() => handleOverflowAction(onOpenSchedules)}
|
|
role="menuitem"
|
|
data-testid="overflow-schedules-btn"
|
|
>
|
|
<Clock size={16} />
|
|
<span>Automation</span>
|
|
</button>
|
|
{/* Activity Log - in overflow on mobile */}
|
|
{onOpenActivityLog && (
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={() => handleOverflowAction(onOpenActivityLog)}
|
|
role="menuitem"
|
|
data-testid="overflow-activity-log-btn"
|
|
>
|
|
<History size={16} />
|
|
<span>View Activity Log</span>
|
|
</button>
|
|
)}
|
|
{/* Mailbox - in overflow on mobile */}
|
|
{onOpenMailbox && (
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={() => handleOverflowAction(onOpenMailbox)}
|
|
role="menuitem"
|
|
data-testid="overflow-mailbox-btn"
|
|
>
|
|
<Mail size={16} />
|
|
<span>Mailbox{mailboxUnreadCount > 0 ? ` (${mailboxUnreadCount})` : ""}</span>
|
|
</button>
|
|
)}
|
|
{/* Usage - in overflow on mobile */}
|
|
{onOpenUsage && (
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={(event) =>
|
|
handleOverflowAction(() => onOpenUsage(event.currentTarget.getBoundingClientRect()))
|
|
}
|
|
role="menuitem"
|
|
data-testid="overflow-usage-btn"
|
|
>
|
|
<Activity size={16} />
|
|
<span>View Usage</span>
|
|
</button>
|
|
)}
|
|
{/* Workflow Steps - in overflow on mobile */}
|
|
{onOpenWorkflowSteps && (
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={() => handleOverflowAction(onOpenWorkflowSteps)}
|
|
role="menuitem"
|
|
data-testid="overflow-workflow-steps-btn"
|
|
>
|
|
<Workflow size={16} />
|
|
<span>Workflow Steps</span>
|
|
</button>
|
|
)}
|
|
{/* Settings - always last in overflow menu */}
|
|
<button
|
|
className="mobile-overflow-item"
|
|
onClick={() => handleOverflowAction(onOpenSettings)}
|
|
role="menuitem"
|
|
>
|
|
<Settings size={16} />
|
|
<span>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="Search tasks..."
|
|
value={searchQuery}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
className="header-search-input"
|
|
/>
|
|
<button
|
|
className="header-search-clear"
|
|
onClick={handleNonMobileSearchClose}
|
|
aria-label="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>Working branch</span>
|
|
<select
|
|
className="header-branch-filter-select"
|
|
value={branchFilter ?? ""}
|
|
onChange={(event) => onBranchFilterChange?.(event.target.value || null)}
|
|
data-testid="working-branch-filter"
|
|
>
|
|
<option value="">All working branches</option>
|
|
<option value={NO_BRANCH_FILTER_VALUE}>No working branch</option>
|
|
{branchOptions.map((option) => (
|
|
<option key={option} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="header-branch-filter-label">
|
|
<span>Target branch</span>
|
|
<select
|
|
className="header-branch-filter-select"
|
|
value={baseBranchFilter ?? ""}
|
|
onChange={(event) => onBaseBranchFilterChange?.(event.target.value || null)}
|
|
data-testid="target-branch-filter"
|
|
>
|
|
<option value="">All target branches</option>
|
|
<option value={NO_BRANCH_FILTER_VALUE}>No target 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="Search tasks..."
|
|
value={searchQuery}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
className="header-search-input"
|
|
/>
|
|
<button
|
|
className="header-search-clear"
|
|
onClick={handleMobileSearchClose}
|
|
aria-label="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>Working branch</span>
|
|
<select
|
|
className="header-branch-filter-select"
|
|
value={branchFilter ?? ""}
|
|
onChange={(event) => onBranchFilterChange?.(event.target.value || null)}
|
|
data-testid="working-branch-filter-mobile"
|
|
>
|
|
<option value="">All working branches</option>
|
|
<option value={NO_BRANCH_FILTER_VALUE}>No working branch</option>
|
|
{branchOptions.map((option) => (
|
|
<option key={option} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="header-branch-filter-label">
|
|
<span>Target branch</span>
|
|
<select
|
|
className="header-branch-filter-select"
|
|
value={baseBranchFilter ?? ""}
|
|
onChange={(event) => onBaseBranchFilterChange?.(event.target.value || null)}
|
|
data-testid="target-branch-filter-mobile"
|
|
>
|
|
<option value="">All target branches</option>
|
|
<option value={NO_BRANCH_FILTER_VALUE}>No target branch</option>
|
|
{baseBranchOptions.map((option) => (
|
|
<option key={option} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|