mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-01 18:13:03 +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 { 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;
|
||||||
|
|
||||||
|
|||||||
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 { 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';
|
||||||
|
|||||||
Reference in New Issue
Block a user