mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-02 10:33:00 +08:00
feat(skills): add Hermes maintenance menu
This commit is contained in:
@@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { cn } from '../../../lib/utils';
|
||||
import {
|
||||
ActionMenu,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
@@ -529,7 +530,7 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
||||
|
||||
const hermesHubPanel = selectedProvider === 'hermes' ? (
|
||||
<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">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<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" />}
|
||||
Search
|
||||
</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>
|
||||
|
||||
{registryResults.length > 0 && (
|
||||
@@ -610,34 +626,6 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
||||
</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>
|
||||
) : null;
|
||||
|
||||
|
||||
189
src/shared/view/ui/ActionMenu.tsx
Normal file
189
src/shared/view/ui/ActionMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export { Alert, AlertTitle, AlertDescription, alertVariants } from './Alert';
|
||||
export { default as ActionMenu } from './ActionMenu';
|
||||
export type { ActionMenuItem } from './ActionMenu';
|
||||
export { Badge, badgeVariants } from './Badge';
|
||||
export { Button, buttonVariants } from './Button';
|
||||
export { Confirmation, ConfirmationTitle, ConfirmationRequest, ConfirmationAccepted, ConfirmationRejected, ConfirmationActions, ConfirmationAction } from './Confirmation';
|
||||
|
||||
Reference in New Issue
Block a user