feat(skills): add Hermes maintenance menu

This commit is contained in:
Simos Mikelatos
2026-06-30 23:29:53 +00:00
parent 655501faba
commit dcd7044258
3 changed files with 208 additions and 29 deletions

View File

@@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next';
import { cn } from '../../../lib/utils'; import { cn } from '../../../lib/utils';
import { import {
ActionMenu,
Badge, Badge,
Button, Button,
Card, Card,
@@ -529,7 +530,7 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
const hermesHubPanel = selectedProvider === 'hermes' ? ( const hermesHubPanel = selectedProvider === 'hermes' ? (
<div className="flex min-h-full flex-col gap-4"> <div className="flex min-h-full flex-col gap-4">
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative min-w-0 flex-1"> <div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
@@ -557,6 +558,21 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
{registryBusyKey === 'search' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />} {registryBusyKey === 'search' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
Search Search
</Button> </Button>
<ActionMenu
label="Maintenance"
icon={Wrench}
className="w-full sm:w-auto"
triggerClassName="w-full sm:w-auto"
items={HERMES_SKILL_ACTIONS.map((action) => ({
key: action.action,
label: action.label,
description: action.description,
icon: action.icon,
loading: registryBusyKey === action.action,
disabled: Boolean(registryBusyKey && registryBusyKey !== action.action),
onSelect: () => void runRegistryMaintenance(action.action),
}))}
/>
</div> </div>
{registryResults.length > 0 && ( {registryResults.length > 0 && (
@@ -610,34 +626,6 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
</div> </div>
)} )}
<div className="rounded-lg border border-border/60 bg-muted/15 p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
<Wrench className="h-3.5 w-3.5" />
Hub Maintenance
</div>
<div className="flex flex-wrap gap-2">
{HERMES_SKILL_ACTIONS.map((action) => {
const Icon = action.icon;
return (
<Button
key={action.action}
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-muted-foreground hover:text-foreground"
title={action.description}
disabled={registryBusyKey === action.action}
onClick={() => void runRegistryMaintenance(action.action)}
>
{registryBusyKey === action.action
? <Loader2 className="h-4 w-4 animate-spin" />
: <Icon className="h-4 w-4" />}
{action.label}
</Button>
);
})}
</div>
</div>
</div> </div>
) : null; ) : null;

View File

@@ -0,0 +1,189 @@
import * as React from 'react';
import { ChevronDown, Loader2, type LucideIcon } from 'lucide-react';
import { cn } from '../../../lib/utils';
import { Button } from './Button';
type ButtonVariant = 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
type ButtonSize = 'default' | 'sm' | 'lg' | 'icon';
export type ActionMenuItem = {
key: string;
label: string;
description?: string;
icon?: LucideIcon;
onSelect: () => void;
disabled?: boolean;
loading?: boolean;
isDanger?: boolean;
showDividerBefore?: boolean;
};
type ActionMenuProps = {
label: string;
items: ActionMenuItem[];
icon?: LucideIcon;
ariaLabel?: string;
align?: 'left' | 'right';
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
triggerClassName?: string;
disabled?: boolean;
};
export default function ActionMenu({
label,
items,
icon: TriggerIcon,
ariaLabel,
align = 'right',
variant = 'outline',
size = 'sm',
className,
triggerClassName,
disabled,
}: ActionMenuProps) {
const [isOpen, setIsOpen] = React.useState(false);
const rootRef = React.useRef<HTMLDivElement | null>(null);
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
const menuRef = React.useRef<HTMLDivElement | null>(null);
// Whether closing should move focus back to the trigger. Set for keyboard
// (Escape) and item selection, but left false for outside pointer clicks so
// focus is not stolen from wherever the user clicked.
const restoreFocusRef = React.useRef(false);
const wasOpenRef = React.useRef(false);
const menuId = React.useId();
React.useEffect(() => {
if (!isOpen) {
return;
}
const closeOnOutsideClick = (event: MouseEvent) => {
const target = event.target as Node;
if (rootRef.current && !rootRef.current.contains(target)) {
setIsOpen(false);
}
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
restoreFocusRef.current = true;
setIsOpen(false);
}
};
document.addEventListener('mousedown', closeOnOutsideClick);
document.addEventListener('keydown', closeOnEscape);
return () => {
document.removeEventListener('mousedown', closeOnOutsideClick);
document.removeEventListener('keydown', closeOnEscape);
};
}, [isOpen]);
// Move focus into the menu on open and back to the trigger on a keyboard or
// selection close, so keyboard and screen-reader navigation match the menu role.
React.useEffect(() => {
if (isOpen) {
wasOpenRef.current = true;
const menu = menuRef.current;
const firstItem = menu?.querySelector<HTMLButtonElement>('[role="menuitem"]:not([disabled])');
(firstItem ?? menu)?.focus();
return;
}
if (wasOpenRef.current) {
wasOpenRef.current = false;
if (restoreFocusRef.current) {
triggerRef.current?.focus();
}
restoreFocusRef.current = false;
}
}, [isOpen]);
const runItem = (item: ActionMenuItem) => {
if (item.disabled || item.loading) {
return;
}
restoreFocusRef.current = true;
setIsOpen(false);
item.onSelect();
};
return (
<div ref={rootRef} className={cn('relative inline-flex', className)}>
<Button
ref={triggerRef}
type="button"
variant={variant}
size={size}
className={triggerClassName}
disabled={disabled}
aria-label={ariaLabel || label}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls={isOpen ? menuId : undefined}
onClick={() => setIsOpen((current) => !current)}
>
{TriggerIcon && <TriggerIcon className="h-4 w-4" />}
<span>{label}</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', isOpen && 'rotate-180')} />
</Button>
{isOpen && (
<div
ref={menuRef}
id={menuId}
role="menu"
tabIndex={-1}
className={cn(
'absolute top-full z-50 mt-2 min-w-[220px] rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg',
'animate-in fade-in-0 zoom-in-95',
align === 'right' ? 'right-0' : 'left-0',
)}
>
{items.map((item) => {
const Icon = item.icon;
return (
<React.Fragment key={item.key}>
{item.showDividerBefore && <div className="mx-2 my-1 h-px bg-border" />}
<button
type="button"
role="menuitem"
disabled={item.disabled || item.loading}
onClick={() => runItem(item)}
className={cn(
'flex w-full items-start gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors',
'focus:bg-accent focus:outline-none',
item.disabled || item.loading
? 'cursor-not-allowed opacity-50'
: item.isDanger
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950'
: 'hover:bg-accent',
)}
>
{item.loading ? (
<Loader2 className="mt-0.5 h-4 w-4 flex-shrink-0 animate-spin" />
) : (
Icon && <Icon className="mt-0.5 h-4 w-4 flex-shrink-0" />
)}
<span className="min-w-0 flex-1">
<span className="block font-medium leading-5">{item.label}</span>
{item.description && (
<span className="mt-0.5 block text-xs leading-4 text-muted-foreground">
{item.description}
</span>
)}
</span>
</button>
</React.Fragment>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,6 @@
export { Alert, AlertTitle, AlertDescription, alertVariants } from './Alert'; export { Alert, AlertTitle, AlertDescription, alertVariants } from './Alert';
export { default as ActionMenu } from './ActionMenu';
export type { ActionMenuItem } from './ActionMenu';
export { Badge, badgeVariants } from './Badge'; export { Badge, badgeVariants } from './Badge';
export { Button, buttonVariants } from './Button'; export { Button, buttonVariants } from './Button';
export { Confirmation, ConfirmationTitle, ConfirmationRequest, ConfirmationAccepted, ConfirmationRejected, ConfirmationActions, ConfirmationAction } from './Confirmation'; export { Confirmation, ConfirmationTitle, ConfirmationRequest, ConfirmationAccepted, ConfirmationRejected, ConfirmationActions, ConfirmationAction } from './Confirmation';