From 41e0d309e06edda14abc6912ade5c2f9d4a90984 Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Mon, 6 Jul 2026 12:22:42 +0200 Subject: [PATCH] feat(redesign): skills and MCP action controls in settings (#942) * feat(settings): refine skills and MCP action controls * feat(settings): open add skill in dialog * fix(settings): stabilize add skill dialog size * fix(skills): scope success banner and add menu focus management Gate the skills install success banner behind a local just-installed flag so it no longer re-appears stale after reopening and cancelling the add dialog, and disable the cancel/close controls while an install is in flight. Add keyboard focus management to ActionMenu: focus the first item on open and restore focus to the trigger on Escape or item selection. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: skill folder picker --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com> --- src/components/mcp/view/McpServers.tsx | 57 +- src/components/skills/view/ProviderSkills.tsx | 629 ++++++++++-------- src/shared/view/ui/ActionMenu.tsx | 189 ++++++ src/shared/view/ui/Dialog.tsx | 5 +- src/shared/view/ui/index.ts | 2 + 5 files changed, 590 insertions(+), 292 deletions(-) create mode 100644 src/shared/view/ui/ActionMenu.tsx diff --git a/src/components/mcp/view/McpServers.tsx b/src/components/mcp/view/McpServers.tsx index 9e15deeb..327fd740 100644 --- a/src/components/mcp/view/McpServers.tsx +++ b/src/components/mcp/view/McpServers.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import type { McpProject, McpProvider, McpScope, ProviderMcpServer } from '../types'; import { IS_PLATFORM } from '../../../constants/config'; -import { Badge, Button } from '../../../shared/view/ui'; +import { ActionMenu, Badge, Button } from '../../../shared/view/ui'; import { MCP_GLOBAL_SUPPORTED_SCOPES, MCP_GLOBAL_SUPPORTED_TRANSPORTS, @@ -134,33 +134,40 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer return (
-
- -

{t('mcpServers.title')}

+
+
+ +
+

{t('mcpServers.title')}

+

{description}

+
+
+ openForm(), + }, + ]} + /> +
-

{description}

-
- - -
{saveStatus === 'success' && ( {t('saveStatus.success')} diff --git a/src/components/skills/view/ProviderSkills.tsx b/src/components/skills/view/ProviderSkills.tsx index 186b6d35..42f7159c 100644 --- a/src/components/skills/view/ProviderSkills.tsx +++ b/src/components/skills/view/ProviderSkills.tsx @@ -7,6 +7,7 @@ import { FileUp, FolderUp, Loader2, + Plus, RefreshCw, Search, Upload, @@ -18,11 +19,9 @@ import { cn } from '../../../lib/utils'; import { Badge, Button, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, + Dialog, + DialogContent, + DialogTitle, Input, } from '../../../shared/view/ui'; import { useProviderSkills } from '../hooks/useProviderSkills'; @@ -215,9 +214,12 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr const [queuedFiles, setQueuedFiles] = useState([]); const [submitError, setSubmitError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const [justInstalled, setJustInstalled] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [showInstallPath, setShowInstallPath] = useState(false); const fileInputRef = useRef(null); - const folderInputRef = useRef(null); + const folderInputRef = useRef(null); const providerName = PROVIDER_NAMES[selectedProvider]; const providerPath = selectedProvider === 'opencode' ? null : PROVIDER_SKILL_PATHS[selectedProvider]; @@ -227,11 +229,19 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr setSubmitError(null); setIsSubmitting(false); setSearchQuery(''); + setIsAddDialogOpen(false); + setShowInstallPath(false); + setJustInstalled(false); }, [selectedProvider]); - useEffect(() => { - folderInputRef.current?.setAttribute('webkitdirectory', ''); - folderInputRef.current?.setAttribute('directory', ''); + const setFolderInputRef = useCallback((node: HTMLInputElement | null) => { + folderInputRef.current = node; + if (!node) { + return; + } + + node.setAttribute('webkitdirectory', ''); + node.setAttribute('directory', ''); }, []); const filteredSkills = useMemo(() => { @@ -354,6 +364,8 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr }))); await addSkills({ entries }); setQueuedFiles([]); + setJustInstalled(true); + setIsAddDialogOpen(false); } catch (error) { setSubmitError(error instanceof Error ? error.message : 'Failed to import skills'); } finally { @@ -361,294 +373,381 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr } }, [addSkills, queuedFiles]); - return ( -
-
-
-
- + const handleAddDialogOpenChange = useCallback((open: boolean) => { + if (open) { + setSubmitError(null); + setShowInstallPath(false); + setJustInstalled(false); + setIsAddDialogOpen(true); + return; + } + + setQueuedFiles([]); + setSubmitError(null); + setShowInstallPath(false); + setJustInstalled(false); + setIsAddDialogOpen(false); + }, []); + + const uploadPanel = ( +
+
+ { + handleDrop(Array.from(event.target.files ?? [])); + event.target.value = ''; + }} + /> + { + handleFolderSelection(Array.from(event.target.files ?? [])); + event.target.value = ''; + }} + /> +
+ +
+
Drop a skill folder or SKILL.md
+
+ Folders can include scripts, references, and assets. +
-
-

{t('tabs.skills', { defaultValue: 'Skills' })}

-

- Install global {providerName} skills from `.md` files or complete skill folders. -

+
+ +
- -
- - -
-
Upload Skills
-
-
Install Path
- {providerPath} -
-
-
- - -
-
- { - handleDrop(Array.from(event.target.files ?? [])); - event.target.value = ''; - }} - /> - { - handleFolderSelection(Array.from(event.target.files ?? [])); - event.target.value = ''; - }} - /> -
- -
-
Drop `.md` files or skill folders here
-
- Upload standalone definitions or choose a full folder to include its scripts, references, and assets. + {queuedFiles.length > 0 && ( +
+
Ready to install
+
+ {queuedFiles.map((queuedFile) => ( +
+
+ {queuedFile.kind === 'folder' ? : } +
+
+
{queuedFile.name}
+
+ {queuedFile.kind === 'folder' + ? `${queuedFile.files.length} files` + : 'Markdown file'} + {' · '} + {formatFileSize(queuedFile.size)}
-
- - -
+
+ ))} +
+
+ )} + + {providerPath && ( +
+ + {showInstallPath && ( +
+ {providerPath}
+ )} +
+ )} - {queuedFiles.length > 0 && ( -
-
Queued Files
-
- {queuedFiles.map((queuedFile) => ( -
-
-
{queuedFile.name}
-
- {queuedFile.kind === 'folder' - ? `${queuedFile.files.length} files` - : 'Markdown file'} - {' · '} - {formatFileSize(queuedFile.size)} -
-
- -
- ))} +
+ ); + + return ( +
+
+
+ +
+
+

{t('tabs.skills', { defaultValue: 'Skills' })}

+

+ Manage {providerName} skills from local files, complete folders, and project-aware locations. +

+
+
+ +
+
+
+ + setSearchQuery(event.target.value)} + placeholder="Search skills..." + aria-label="Search skills" + className="h-9 w-full pl-9 pr-9" + /> + {searchQuery && ( + + )} +
+ + +
+ {isLoadingProjectScopes && ( +
+ + Scanning project skills... +
+ )} +
+ + + + Add {providerName} Skill +
+
+
+ +
+
+
Add {providerName} Skill
+
+ Upload a SKILL.md file or a complete skill folder.
- )} - -
- - Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`. -
- {(submitError || loadError) && ( -
- {submitError || loadError} -
- )} +
+ {uploadPanel} +
- {saveStatus === 'success' && ( -
- - Skills saved successfully. -
- )} - - - - - -
-
- Visible Skills - - The list below comes from the provider skill discovery API and includes global and project-aware locations. - -
-
- - setSearchQuery(event.target.value)} - placeholder="Search skills..." - aria-label="Search visible skills" - className="h-9 w-full pl-9 pr-9" - /> - {searchQuery && ( - +
+
+ {(submitError || loadError || (justInstalled && saveStatus === 'success')) ? ( +
+ {submitError || loadError || 'Skills saved successfully.'} +
+ ) : ( + + Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`. + )}
- {isLoadingProjectScopes && ( -
- - Scanning project skills… -
- )} +
+ + +
- + +
- - {isLoading && skills.length === 0 && ( -
- Loading {providerName} skills… + {!isAddDialogOpen && (submitError || loadError) && ( +
+ {submitError || loadError} +
+ )} + + {justInstalled && saveStatus === 'success' && !isAddDialogOpen && ( +
+ + Skills saved successfully. +
+ )} + +
+ {isLoading && skills.length === 0 && ( +
+ Loading {providerName} skills… +
+ )} + + {!isLoading && skills.length === 0 && ( +
+
+
- )} - - {!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( -
+
{/* Overlay */}