+
+
- )}
-
- {!isLoading && skills.length === 0 && (
-
-
-
-
-
No skills discovered yet
-
- Add a global skill above or create project-specific skill folders in your workspace.
-
+
No skills discovered yet
+
+ Add a global skill above or create project-specific skill folders in your workspace.
- )}
+
+ )}
- {!isLoading && skills.length > 0 && filteredSkills.length === 0 && (
-
-
-
No matching skills
-
- Try a different command, name, scope, project, or source path.
-
+ {!isLoading && skills.length > 0 && filteredSkills.length === 0 && (
+
+
+
No matching skills
+
+ Try a different command, name, scope, project, or source path.
- )}
+
+ )}
- {groupedSkills.map((group) => (
-
-
-
- {SCOPE_LABELS[group.scope]}
-
-
- {group.skills.length} skill{group.skills.length === 1 ? '' : 's'}
-
-
+ {groupedSkills.map((group) => (
+
+
+
+ {SCOPE_LABELS[group.scope]}
+
+
+ {group.skills.length} skill{group.skills.length === 1 ? '' : 's'}
+
+
-
- {group.skills.map((skill) => (
-
-
-
{skill.command}
-
{skill.name}
-
-
-
- {skill.description || 'No description provided in the skill front matter.'}
-
-
-
- {skill.pluginName && (
-
- Plugin: {skill.pluginName}
-
- )}
- {skill.projectDisplayName && (
-
- Project: {skill.projectDisplayName}
-
- )}
-
-
-
-
Source
-
{skill.sourcePath}
-
+
+ {group.skills.map((skill) => (
+
+
+
{skill.command}
+
{skill.name}
- ))}
-
-
- ))}
-
-
+
+
+ {skill.description || 'No description provided in the skill front matter.'}
+
+
+
+ {skill.pluginName && (
+
+ Plugin: {skill.pluginName}
+
+ )}
+ {skill.projectDisplayName && (
+
+ Project: {skill.projectDisplayName}
+
+ )}
+
+
+
+
Source
+
{skill.sourcePath}
+
+
+ ))}
+
+
+ ))}
+
);
}
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/Dialog.tsx b/src/shared/view/ui/Dialog.tsx
index a3fb3740..f40a7a20 100644
--- a/src/shared/view/ui/Dialog.tsx
+++ b/src/shared/view/ui/Dialog.tsx
@@ -92,12 +92,13 @@ DialogTrigger.displayName = 'DialogTrigger';
interface DialogContentProps extends React.HTMLAttributes {
onEscapeKeyDown?: () => void;
onPointerDownOutside?: () => void;
+ wrapperClassName?: string;
}
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const DialogContent = React.forwardRef(
- ({ className, children, onEscapeKeyDown, onPointerDownOutside, ...props }, ref) => {
+ ({ className, children, onEscapeKeyDown, onPointerDownOutside, wrapperClassName, ...props }, ref) => {
const { open, onOpenChange, triggerRef } = useDialog();
const contentRef = React.useRef(null);
const previousFocusRef = React.useRef(null);
@@ -171,7 +172,7 @@ const DialogContent = React.forwardRef(
if (!open) return null;
return createPortal(
-