diff --git a/src/components/mcp/view/McpServers.tsx b/src/components/mcp/view/McpServers.tsx index a77117fe..8bd0849c 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,39 @@ 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..099452a3 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,6 @@ import { cn } from '../../../lib/utils'; import { Badge, Button, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, Input, } from '../../../shared/view/ui'; import { useProviderSkills } from '../hooks/useProviderSkills'; @@ -216,6 +212,8 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr const [submitError, setSubmitError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + const [isAddPanelOpen, setIsAddPanelOpen] = useState(false); + const [showInstallPath, setShowInstallPath] = useState(false); const fileInputRef = useRef(null); const folderInputRef = useRef(null); @@ -227,6 +225,8 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr setSubmitError(null); setIsSubmitting(false); setSearchQuery(''); + setIsAddPanelOpen(false); + setShowInstallPath(false); }, [selectedProvider]); useEffect(() => { @@ -354,6 +354,7 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr }))); await addSkills({ entries }); setQueuedFiles([]); + setIsAddPanelOpen(false); } catch (error) { setSubmitError(error instanceof Error ? error.message : 'Failed to import skills'); } finally { @@ -361,294 +362,323 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr } }, [addSkills, queuedFiles]); - return ( -
-
-
-
- + 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)} -
-
- -
- ))} -
-
- )} +
+ + Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`. + +
+ + +
+
+
+ ); -
- - - Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`. + + + )} +
+ + +
+ {isLoadingProjectScopes && ( +
+ + Scanning project skills... +
+ )} +
+ + {isAddPanelOpen && uploadPanel} + + {(submitError || loadError) && ( +
+ {submitError || loadError} +
+ )} + + {saveStatus === 'success' && !isAddPanelOpen && ( +
+ + Skills saved successfully. +
+ )} + +
+ {isLoading && skills.length === 0 && ( +
+ Loading {providerName} skills… +
+ )} + + {!isLoading && skills.length === 0 && ( +
+
+ +
+
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. +
+
+ )} + + {groupedSkills.map((group) => ( +
+
+ + {SCOPE_LABELS[group.scope]} + + + {group.skills.length} skill{group.skills.length === 1 ? '' : 's'}
-
- {(submitError || loadError) && ( -
- {submitError || loadError} -
- )} - - {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 && ( - - )} -
- {isLoadingProjectScopes && ( -
- - Scanning project skills… -
- )} -
-
- - - {isLoading && skills.length === 0 && ( -
- Loading {providerName} skills… -
- )} - - {!isLoading && skills.length === 0 && ( -
-
- -
-
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. -
-
- )} - - {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} -
+
+
{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..54d0afc8 --- /dev/null +++ b/src/shared/view/ui/ActionMenu.tsx @@ -0,0 +1,157 @@ +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 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') { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', closeOnOutsideClick); + document.addEventListener('keydown', closeOnEscape); + return () => { + document.removeEventListener('mousedown', closeOnOutsideClick); + document.removeEventListener('keydown', closeOnEscape); + }; + }, [isOpen]); + + const runItem = (item: ActionMenuItem) => { + if (item.disabled || item.loading) { + return; + } + + 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';