feat(FN-2655): inline quick scripts into header terminal split-button

- Replace QuickScriptsDropdown with an inline Header implementation for terminal script actions
- Add split-button interaction logic in Header to support default-run and dropdown behavior
- Update Header CSS for split-button layout and mobile overflow handling
- Expand Header tests to cover split-button behavior and remove obsolete QuickScriptsDropdown tests
This commit is contained in:
Fusion
2026-04-27 00:39:28 -07:00
committed by gsxdsm
parent cf12664e5a
commit 85bbf2cd43
5 changed files with 495 additions and 939 deletions

View File

@@ -231,10 +231,6 @@
.btn-icon:active {
opacity: 0.7;
}
.btn-icon--terminal {
position: relative;
}
.btn-icon--terminal:disabled {
opacity: 0.4;
cursor: not-allowed;
@@ -285,6 +281,33 @@
}
}
/* Split-button for terminal + scripts controls */
.terminal-split-btn {
position: relative;
display: flex;
align-items: center;
border-radius: var(--radius-md);
}
.terminal-split-btn__main {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.terminal-split-btn__chevron {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
min-width: 28px;
padding: 0;
}
.terminal-split-btn__divider {
width: 1px;
height: 16px;
background: var(--border);
flex-shrink: 0;
}
/* Split-button for engine controls */
.engine-control-split-btn {
position: relative;

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent as ReactKeyboardEvent } 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, Map, Zap, Sparkles, FileText, Brain, CheckSquare } from "lucide-react";
import "./Header.css";
// Header renders an inline ProjectSelector dropdown using project-selector-* classes.
@@ -6,7 +6,6 @@ import "./ProjectSelector.css";
import type { ProjectInfo } from "../api";
import type { NodeConfig, ProjectStatus } from "@fusion/core";
import { fetchScripts } from "../api";
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
import { NodeStatusIndicator } from "./NodeStatusIndicator";
import { PluginSlot } from "./PluginSlot";
import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
@@ -155,6 +154,12 @@ function GitHubLogo({ size = 16 }: { size?: number }) {
);
}
interface DropdownPosition {
top: number;
left: number;
width: number;
}
export interface HeaderProps {
onOpenSettings?: () => void;
onOpenGitHubImport?: () => void;
@@ -275,6 +280,11 @@ export function Header({
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);
@@ -289,6 +299,10 @@ export function Header({
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(() =>
@@ -297,6 +311,14 @@ export function Header({
);
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));
@@ -315,11 +337,139 @@ export function Header({
);
}, [experimentalFeatures, onChangeView, showSkillsTab]);
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;
@@ -349,6 +499,121 @@ export function Header({
};
}, [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;
@@ -430,6 +695,11 @@ export function Header({
setIsTerminalSubmenuOpen(false);
return;
}
if (scriptsOpenRef.current) {
setIsScriptsOpen(false);
scriptsChevronButtonRef.current?.focus();
return;
}
setIsOverflowMenuOpen(false);
setIsMobileSearchOpen(false);
setIsNodeSelectorOpen(false);
@@ -1000,16 +1270,115 @@ export function Header({
</button>
)}
{/* Terminal button - desktop only (moved to overflow on mobile/tablet) */}
{/* Terminal split button - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && (
<button
className="btn-icon btn-icon--terminal"
onClick={onToggleTerminal}
title="Open Terminal"
data-testid="terminal-toggle-btn"
>
<Terminal size={16} />
</button>
<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) */}
@@ -1048,15 +1417,6 @@ export function Header({
</button>
)}
{/* Scripts - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenScripts && onRunScript && (
<QuickScriptsDropdown
onOpenScripts={onOpenScripts}
onRunScript={onRunScript}
projectId={projectId}
/>
)}
{/* Desktop overflow menu for Nodes and Schedules */}
{!isCompact && (
<div style={{ position: "relative" }}>

View File

@@ -1,398 +0,0 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { Terminal, Play, Settings, Loader2, ChevronDown } from "lucide-react";
import { fetchScripts } from "../api";
interface DropdownPosition {
top: number;
left: number;
width: number;
}
export interface QuickScriptsDropdownProps {
onOpenScripts: () => void;
onRunScript: (name: string, command: string) => void;
projectId?: string;
}
/**
* QuickScriptsDropdown - Dropdown for quick script execution
*
* Features:
* - Dropdown trigger with Terminal icon + chevron
* - Fetches and displays all available scripts
* - Click to run script immediately (opens terminal)
* - "Manage Scripts..." footer to open full modal
* - Keyboard navigation: arrow keys, enter to run, escape to close
* - Loading state while fetching
* - Empty state when no scripts configured
* - Closes on outside click
*/
export function QuickScriptsDropdown({
onOpenScripts,
onRunScript,
projectId,
}: QuickScriptsDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [scripts, setScripts] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
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 updateDropdownPosition = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const menu = menuRef.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 triggerLeft = rect.left - offsetLeft;
const spaceBelow = viewportHeight - triggerBottom;
const spaceAbove = triggerTop;
const openUpward = spaceBelow < constrainedHeight && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(triggerLeft, 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,
);
setDropdownPosition({ top, left, width });
}, [getEffectiveViewport]);
// Script entries sorted alphabetically
const scriptEntries = useMemo(() => {
return Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b));
}, [scripts]);
const showFooter = scriptEntries.length > 0;
// Total items for keyboard navigation (scripts + "Manage Scripts...")
const totalItems = scriptEntries.length + (showFooter ? 1 : 0);
// Fetch scripts when dropdown opens
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
setLoading(true);
fetchScripts(projectId)
.then((data) => {
if (!cancelled) {
setScripts(data);
}
})
.catch(() => {
if (!cancelled) {
setScripts({});
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [isOpen, projectId]);
// Close dropdown on outside click
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
// Close on escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsOpen(false);
triggerRef.current?.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen]);
// Reset highlight when dropdown opens and focus the menu
useEffect(() => {
if (isOpen) {
setHighlightedIndex(-1);
// Focus menu for keyboard navigation
const timeoutId = window.setTimeout(() => menuRef.current?.focus(), 0);
return () => window.clearTimeout(timeoutId);
}
setDropdownPosition(null);
}, [isOpen]);
// Position dropdown when opening and whenever content size changes.
useEffect(() => {
if (!isOpen) return;
const rafId = requestAnimationFrame(() => {
updateDropdownPosition();
});
return () => cancelAnimationFrame(rafId);
}, [isOpen, loading, scriptEntries.length, showFooter, updateDropdownPosition]);
// Keep dropdown anchored on viewport and container changes.
useEffect(() => {
if (!isOpen) return;
const handleReposition = () => updateDropdownPosition();
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);
}
};
}, [isOpen, updateDropdownPosition]);
// Handle keyboard navigation within dropdown
const handleDropdownKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIndex((prev) =>
prev < totalItems - 1 ? prev + 1 : 0
);
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((prev) =>
prev > 0 ? prev - 1 : totalItems - 1
);
break;
case "Enter":
e.preventDefault();
if (highlightedIndex >= 0) {
if (highlightedIndex < scriptEntries.length) {
// Run script
const [name, command] = scriptEntries[highlightedIndex];
handleRunScript(name, command);
} else if (showFooter && highlightedIndex === scriptEntries.length) {
// Manage Scripts...
handleManageScripts();
}
}
break;
case "Home":
e.preventDefault();
setHighlightedIndex(0);
break;
case "End":
e.preventDefault();
setHighlightedIndex(totalItems - 1);
break;
}
},
[highlightedIndex, totalItems, scriptEntries, showFooter]
);
// Toggle dropdown
const toggleDropdown = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);
// Handle run script
const handleRunScript = useCallback(
(name: string, command: string) => {
onRunScript(name, command);
setIsOpen(false);
},
[onRunScript]
);
// Handle manage scripts
const handleManageScripts = useCallback(() => {
onOpenScripts();
setIsOpen(false);
}, [onOpenScripts]);
return (
<div className="quick-scripts-dropdown" ref={dropdownRef}>
{/* Trigger button */}
<button
ref={triggerRef}
className={`btn-icon quick-scripts-dropdown__trigger ${isOpen ? "open btn-icon--active" : ""}`}
onClick={toggleDropdown}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label="Quick scripts"
data-testid="scripts-btn"
title="Scripts"
>
<Terminal size={16} />
<ChevronDown
size={14}
className={`quick-scripts-dropdown__trigger-chevron ${isOpen ? "rotate" : ""}`}
/>
</button>
{/* Dropdown menu */}
{isOpen && (
<div
ref={menuRef}
tabIndex={-1}
className="quick-scripts-dropdown__menu"
role="listbox"
aria-label="Scripts"
onKeyDown={handleDropdownKeyDown}
data-testid="quick-scripts-dropdown"
style={
dropdownPosition
? {
position: "fixed",
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
right: "auto",
}
: undefined
}
>
{loading ? (
<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>
) : (
<>
{/* Script list */}
<div className="quick-scripts-dropdown__list">
{scriptEntries.map(([name, command], index) => (
<button
key={name}
className={`quick-scripts-dropdown__item ${
highlightedIndex === index ? "highlighted" : ""
}`}
onClick={() => handleRunScript(name, command)}
role="option"
aria-selected={highlightedIndex === 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>
{/* Footer */}
<div className="quick-scripts-dropdown__footer">
<button
className={`quick-scripts-dropdown__manage ${
showFooter && highlightedIndex === scriptEntries.length ? "highlighted" : ""
}`}
onClick={handleManageScripts}
data-testid="quick-scripts-manage"
>
<Settings size={14} />
<span>Manage Scripts...</span>
</button>
</div>
</>
)}
</div>
)}
</div>
);
}

View File

@@ -177,10 +177,11 @@ describe("Header", () => {
});
});
describe("terminal button", () => {
it("renders terminal button with correct title on desktop", () => {
renderHeader({ onToggleTerminal: noop }, "desktop");
describe("terminal split button", () => {
it("renders terminal main button and scripts chevron on desktop", () => {
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "desktop");
expect(screen.getByTitle("Open Terminal")).toBeDefined();
expect(screen.getByTestId("scripts-btn")).toBeDefined();
});
it("does not render terminal button inline on mobile", () => {
@@ -188,11 +189,91 @@ describe("Header", () => {
expect(screen.queryByTitle("Open Terminal")).toBeNull();
});
it("calls onToggleTerminal when terminal button is clicked", () => {
it("clicking main button calls onToggleTerminal without opening scripts dropdown", () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal }, "desktop");
fireEvent.click(screen.getByTitle("Open Terminal"));
expect(onToggleTerminal).toHaveBeenCalled();
renderHeader({ onToggleTerminal, onOpenScripts: noop, onRunScript: noop }, "desktop");
fireEvent.click(screen.getByTestId("terminal-toggle-btn"));
expect(onToggleTerminal).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
it("clicking scripts chevron opens dropdown without calling onToggleTerminal", async () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal, onOpenScripts: noop, onRunScript: noop }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
expect(onToggleTerminal).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
});
it("fetches scripts and runs selected script from dropdown", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
const onRunScript = vi.fn();
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript, projectId: "proj-1" }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
});
fireEvent.click(screen.getByTestId("quick-script-item-build"));
expect(onRunScript).toHaveBeenCalledWith("build", "pnpm build");
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
it("shows loading state while scripts are fetching", () => {
mockFetchScripts.mockImplementation(() => new Promise(() => {}));
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
expect(screen.getByTestId("quick-scripts-loading")).toBeDefined();
});
it("shows empty state when no scripts are configured", async () => {
mockFetchScripts.mockResolvedValue({});
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
});
});
it("shows manage scripts footer when scripts exist", async () => {
mockFetchScripts.mockResolvedValue({ test: "pnpm test" });
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
});
});
it("supports keyboard navigation in scripts dropdown", async () => {
mockFetchScripts.mockResolvedValue({ alpha: "echo alpha", beta: "echo beta" });
const onRunScript = vi.fn();
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
fireEvent.keyDown(menu, { key: "ArrowDown" });
fireEvent.keyDown(menu, { key: "Enter" });
expect(onRunScript).toHaveBeenCalledWith("alpha", "echo alpha");
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
fireEvent.keyDown(screen.getByTestId("quick-scripts-dropdown"), { key: "Escape" });
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
it("is always enabled regardless of task state", () => {

View File

@@ -1,510 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { QuickScriptsDropdown } from "../QuickScriptsDropdown";
// Mock the API functions
const mockFetchScripts = vi.fn();
vi.mock("../../api", () => ({
fetchScripts: () => mockFetchScripts(),
}));
const mockOnOpenScripts = vi.fn();
const mockOnRunScript = vi.fn();
function renderDropdown(props = {}) {
return render(
<QuickScriptsDropdown
onOpenScripts={mockOnOpenScripts}
onRunScript={mockOnRunScript}
{...props}
/>
);
}
describe("QuickScriptsDropdown", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("rendering", () => {
it("renders the trigger button", () => {
renderDropdown();
expect(screen.getByTestId("scripts-btn")).toBeDefined();
expect(screen.getByTitle("Scripts")).toBeDefined();
});
it("does not show dropdown menu initially", () => {
renderDropdown();
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
describe("dropdown open/close", () => {
it("opens dropdown when trigger is clicked", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
});
it("closes dropdown when clicking outside", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
fireEvent.mouseDown(document.body);
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
it("closes dropdown on Escape key", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
it("closes dropdown when trigger is clicked again", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
});
describe("fetching and displaying scripts", () => {
it("shows loading state while fetching", async () => {
mockFetchScripts.mockImplementation(() => new Promise(() => {}));
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
expect(screen.getByTestId("quick-scripts-loading")).toBeDefined();
});
it("fetches and displays scripts", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
test: "npm test",
lint: "npm run lint",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
expect(screen.getByTestId("quick-script-item-test")).toBeDefined();
expect(screen.getByTestId("quick-script-item-lint")).toBeDefined();
});
});
it("displays script names and truncated commands", async () => {
mockFetchScripts.mockResolvedValue({
"long-command": "this is a very long command that should be truncated",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
const item = screen.getByTestId("quick-script-item-long-command");
expect(item.textContent).toContain("long-command");
expect(item.textContent).toContain("this is a very long command that should be truncat...");
});
});
it("handles short commands without truncation", async () => {
mockFetchScripts.mockResolvedValue({
short: "echo hi",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
const item = screen.getByTestId("quick-script-item-short");
expect(item.textContent).toContain("short");
expect(item.textContent).toContain("echo hi");
});
});
it("sorts scripts alphabetically", async () => {
mockFetchScripts.mockResolvedValue({
zebra: "echo zebra",
alpha: "echo alpha",
beta: "echo beta",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
const items = screen.getAllByRole("option");
expect(items[0].textContent).toContain("alpha");
expect(items[1].textContent).toContain("beta");
expect(items[2].textContent).toContain("zebra");
});
});
});
describe("running scripts", () => {
it("calls onRunScript when a script is clicked", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
});
fireEvent.click(screen.getByTestId("quick-script-item-build"));
expect(mockOnRunScript).toHaveBeenCalledWith("build", "npm run build");
});
it("closes dropdown after running script", async () => {
mockFetchScripts.mockResolvedValue({
test: "npm test",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-test")).toBeDefined();
});
fireEvent.click(screen.getByTestId("quick-script-item-test"));
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
});
describe("manage scripts link", () => {
it("shows 'Manage Scripts...' link when scripts exist", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
});
});
it("calls onOpenScripts when 'Manage Scripts...' is clicked", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
});
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
expect(mockOnOpenScripts).toHaveBeenCalled();
});
it("closes dropdown when 'Manage Scripts...' is clicked", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
});
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
});
describe("empty state", () => {
it("shows empty state when no scripts configured", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
});
});
it("empty state shows 'Add your first script' button", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByText("Add your first script")).toBeDefined();
});
});
it("clicking 'Add your first script' calls onOpenScripts", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByText("Add your first script")).toBeDefined();
});
fireEvent.click(screen.getByText("Add your first script"));
expect(mockOnOpenScripts).toHaveBeenCalled();
});
it("closes dropdown when empty state action is clicked", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByText("Add your first script")).toBeDefined();
});
fireEvent.click(screen.getByText("Add your first script"));
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
});
describe("keyboard navigation", () => {
it("supports ArrowDown to highlight items", async () => {
mockFetchScripts.mockResolvedValue({
alpha: "echo alpha",
beta: "echo beta",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// First ArrowDown highlights first item
fireEvent.keyDown(menu, { key: "ArrowDown" });
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
// Second ArrowDown highlights second item
fireEvent.keyDown(menu, { key: "ArrowDown" });
expect(screen.getByTestId("quick-script-item-beta").className).toContain("highlighted");
});
it("supports ArrowUp to highlight items", async () => {
mockFetchScripts.mockResolvedValue({
alpha: "echo alpha",
beta: "echo beta",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// Go to bottom first with End key
fireEvent.keyDown(menu, { key: "End" });
// ArrowUp moves to previous item
fireEvent.keyDown(menu, { key: "ArrowUp" });
expect(screen.getByTestId("quick-script-item-beta").className).toContain("highlighted");
});
it("wraps around with arrow keys", async () => {
mockFetchScripts.mockResolvedValue({
alpha: "echo alpha",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// ArrowUp from start wraps to end (Manage Scripts...)
fireEvent.keyDown(menu, { key: "ArrowUp" });
expect(screen.getByTestId("quick-scripts-manage").className).toContain("highlighted");
// ArrowDown from end wraps to start
fireEvent.keyDown(menu, { key: "ArrowDown" });
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
});
it("runs script with Enter key", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// Highlight and press Enter
fireEvent.keyDown(menu, { key: "ArrowDown" });
fireEvent.keyDown(menu, { key: "Enter" });
expect(mockOnRunScript).toHaveBeenCalledWith("build", "npm run build");
});
it("opens manage scripts with Enter key on manage button", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// Navigate to last item (Manage Scripts...) and press Enter
fireEvent.keyDown(menu, { key: "End" });
fireEvent.keyDown(menu, { key: "Enter" });
expect(mockOnOpenScripts).toHaveBeenCalled();
});
it("supports Home key to go to first item", async () => {
mockFetchScripts.mockResolvedValue({
alpha: "echo alpha",
beta: "echo beta",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// Go to end first
fireEvent.keyDown(menu, { key: "End" });
// Home goes to first
fireEvent.keyDown(menu, { key: "Home" });
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
});
it("supports End key to go to last item", async () => {
mockFetchScripts.mockResolvedValue({
alpha: "echo alpha",
beta: "echo beta",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
fireEvent.keyDown(menu, { key: "End" });
expect(screen.getByTestId("quick-scripts-manage").className).toContain("highlighted");
});
});
describe("error handling", () => {
it("handles fetch errors gracefully", async () => {
mockFetchScripts.mockRejectedValue(new Error("Failed to fetch"));
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
// Should show empty state since scripts will be empty object on error
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
});
});
});
describe("focus management", () => {
it("menu is focusable with tabIndex", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
expect(menu).toHaveAttribute("tabIndex", "-1");
});
it("focus moves to trigger when Escape is pressed", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
// Trigger should have focus
expect(document.activeElement).toBe(screen.getByTestId("scripts-btn"));
});
});
});