diff --git a/src/components/skills/view/ProviderSkills.tsx b/src/components/skills/view/ProviderSkills.tsx
index 33c42e98..e3a11216 100644
--- a/src/components/skills/view/ProviderSkills.tsx
+++ b/src/components/skills/view/ProviderSkills.tsx
@@ -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' ? (
-
+
{registryResults.length > 0 && (
@@ -610,34 +626,6 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
)}
-
-
-
- Hub Maintenance
-
-
- {HERMES_SKILL_ACTIONS.map((action) => {
- const Icon = action.icon;
- return (
-
- );
- })}
-
-
) : null;
diff --git a/src/shared/view/ui/ActionMenu.tsx b/src/shared/view/ui/ActionMenu.tsx
new file mode 100644
index 00000000..bf83690d
--- /dev/null
+++ b/src/shared/view/ui/ActionMenu.tsx
@@ -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
(null);
+ const triggerRef = React.useRef(null);
+ const menuRef = React.useRef(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('[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 (
+
+
+
+ {isOpen && (
+
+ )}
+
+ );
+}
diff --git a/src/shared/view/ui/index.ts b/src/shared/view/ui/index.ts
index c75e952f..2665fcd7 100644
--- a/src/shared/view/ui/index.ts
+++ b/src/shared/view/ui/index.ts
@@ -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';